├── .github └── FUNDING.yml ├── miscale ├── miscale_esp32.cpp ├── miscale_export.py ├── s400_ble.py ├── body_scales.py ├── miscale_esp32.ino ├── miscale_ble.py └── Xiaomi_Scale_Body_Metrics.py ├── manuals ├── workflow.png ├── Miscale_ESP32.jpg ├── Miscale_ESP32_win.md ├── all_BLE_win.md ├── about_BLE.md ├── Miscale_BLE.md ├── Omron_BLE.md ├── Miscale_ESP32.md └── S400_BLE.md ├── omron ├── omron_pairing.sh ├── deviceSpecific │ ├── hem-7530t.py │ ├── hem-7600t.py │ ├── hem-7322t.py │ ├── hem-7150t.py │ ├── hem-7342t.py │ ├── hem-7361t.py │ ├── hem-7155t.py │ └── hem-6232t.py ├── omron_export.py ├── sharedDriver.py └── omblepy.py ├── user ├── import_tokens.py └── export2garmin.cfg ├── CODE_OF_CONDUCT.md ├── README.md └── import_data.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: RobertWojtowicz -------------------------------------------------------------------------------- /miscale/miscale_esp32.cpp: -------------------------------------------------------------------------------- 1 | #include "miscale_esp32.ino" -------------------------------------------------------------------------------- /manuals/workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobertWojtowicz/export2garmin/HEAD/manuals/workflow.png -------------------------------------------------------------------------------- /manuals/Miscale_ESP32.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobertWojtowicz/export2garmin/HEAD/manuals/Miscale_ESP32.jpg -------------------------------------------------------------------------------- /omron/omron_pairing.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Version info 4 | echo -e "\n===============================================" 5 | echo -e "Export 2 Garmin Connect v3.0 (omron_pairing.sh)" 6 | echo -e "===============================================\n" 7 | 8 | # Verifying correct working of BLE, restart bluetooth service and device via miscale_ble.py 9 | path=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd) 10 | timenow() { date +%d.%m.%Y-%H:%M:%S; } 11 | echo "$(timenow) SYSTEM * BLE adapter check if available" 12 | ble_check=$(python3 -B $path/miscale/miscale_ble.py) 13 | if echo $ble_check | grep -q "failed" ; then 14 | echo "$(timenow) SYSTEM * BLE adapter not working, skip pairing" 15 | else ble_status=ok 16 | hci_mac=$(echo $ble_check | grep -o 'h.\{21\})' | head -n 1) 17 | echo "$(timenow) SYSTEM * BLE adapter $hci_mac working, go to pairing" 18 | fi 19 | 20 | # Workaround for pairing 21 | if [[ $ble_status == "ok" ]] ; then 22 | source <(grep omron_omblepy_ $path/user/export2garmin.cfg) 23 | omron_hci=$(echo $ble_check | grep -o 'hci.' | head -n 1) 24 | coproc bluetoothctl 25 | if [ $omron_omblepy_debug == "on" ] ; then 26 | python3 -B $path/omron/omblepy.py -a $omron_hci -p -d $omron_omblepy_model --loggerDebug 27 | else 28 | python3 -B $path/omron/omblepy.py -a $omron_hci -p -d $omron_omblepy_model 29 | fi 30 | fi -------------------------------------------------------------------------------- /omron/deviceSpecific/hem-7530t.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import logging 4 | logger = logging.getLogger("omblepy") 5 | 6 | sys.path.append('..') 7 | from sharedDriver import sharedDeviceDriverCode 8 | 9 | class deviceSpecificDriver(sharedDeviceDriverCode): 10 | deviceEndianess = "big" 11 | userStartAdressesList = [0x2e8] 12 | perUserRecordsCountList = [90] 13 | recordByteSize = 0x0e 14 | transmissionBlockSize = 0x10 15 | 16 | settingsReadAddress = 0x0260 17 | settingsWriteAddress = 0x02a4 18 | 19 | #settingsUnreadRecordsBytes = [0x00, 0x08] 20 | #settingsTimeSyncBytes = [0x14, 0x1e] 21 | 22 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 23 | recordDict = dict() 24 | recordDict["dia"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 0, 7) 25 | recordDict["sys"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 8, 15) + 25 26 | year = self._bytearrayBitsToInt(singleRecordAsByteArray, 18, 23) + 2000 27 | recordDict["bpm"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 24, 31) 28 | recordDict["mov"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 32, 32) 29 | recordDict["ihb"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 33, 33) 30 | month = self._bytearrayBitsToInt(singleRecordAsByteArray, 34, 37) 31 | day = self._bytearrayBitsToInt(singleRecordAsByteArray, 38, 42) 32 | hour = self._bytearrayBitsToInt(singleRecordAsByteArray, 43, 47) 33 | minute = self._bytearrayBitsToInt(singleRecordAsByteArray, 52, 57) 34 | second = self._bytearrayBitsToInt(singleRecordAsByteArray, 58, 63) 35 | second = min([second, 59]) #for some reason the second value can range up to 63 36 | recordDict["datetime"] = datetime.datetime(year, month, day, hour, minute, second) 37 | return recordDict 38 | 39 | def deviceSpecific_syncWithSystemTime(self): 40 | raise ValueError("not supported") -------------------------------------------------------------------------------- /user/import_tokens.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import datetime 5 | import requests 6 | from getpass import getpass 7 | from garth.exc import GarthHTTPError 8 | from garminconnect import Garmin, GarminConnectAuthenticationError 9 | 10 | # Version info 11 | print(""" 12 | =============================================== 13 | Export 2 Garmin Connect v3.0 (import_tokens.py) 14 | =============================================== 15 | """) 16 | 17 | # Importing tokens variables from a file 18 | path = os.path.dirname(os.path.dirname(__file__)) 19 | with open(path + '/user/export2garmin.cfg', 'r') as file: 20 | for line in file: 21 | line = line.strip() 22 | if line.startswith('tokens_is_cn'): 23 | name, value = line.split('=') 24 | globals()[name.strip()] = value.strip() == 'True' 25 | 26 | # Get user credentials 27 | def get_credentials(): 28 | email = input(datetime.datetime.now().strftime("%d.%m.%Y-%H:%M:%S") + " * Login e-mail: ") 29 | password = getpass(datetime.datetime.now().strftime("%d.%m.%Y-%H:%M:%S") + " * Enter password: ") 30 | return email, password 31 | def get_mfa(): 32 | return input(datetime.datetime.now().strftime("%d.%m.%Y-%H:%M:%S") + " * MFA/2FA one-time code: ") 33 | 34 | # Initialize Garmin API with your credentials without/and MFA/2FA 35 | def init_api(): 36 | try: 37 | email, password = get_credentials() 38 | garmin = Garmin(email, password, is_cn=tokens_is_cn, return_on_mfa=True) 39 | result1, result2 = garmin.login() 40 | if result1 == "needs_mfa": 41 | mfa_code = get_mfa() 42 | garmin.resume_login(result2, mfa_code) 43 | 44 | # Create Oauth1 and Oauth2 tokens as base64 encoded string 45 | tokenstore_base64 = os.path.dirname(os.path.abspath(__file__)) 46 | token_base64 = garmin.garth.dumps() 47 | dir_path = os.path.expanduser(os.path.join(tokenstore_base64, email)) 48 | with open(dir_path, "w") as token_file: 49 | token_file.write(token_base64) 50 | print(datetime.datetime.now().strftime("%d.%m.%Y-%H:%M:%S") + " * Oauth tokens saved correctly") 51 | except (FileNotFoundError, GarthHTTPError, GarminConnectAuthenticationError, requests.exceptions.HTTPError) as err: 52 | print(err) 53 | return None 54 | 55 | # Main program loop 56 | if __name__ == "__main__": 57 | init_api() -------------------------------------------------------------------------------- /manuals/Miscale_ESP32_win.md: -------------------------------------------------------------------------------- 1 | ## 2.5. Miscale_ESP32_WIN VERSION 2 | 3 | ### 2.5.3. Preparing host operating system 4 | - It is possible to run Linux as a virtual machine in Windows 11 by installing Hyper-V with powershell: 5 | ``` 6 | PS> Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All 7 | ``` 8 | - Check host IP address (network adapter with default gateway), create a virtual switch in bridge mode: 9 | ``` 10 | PS> Get-NetIPConfiguration | Where-Object {$_.IPv4DefaultGateway -ne $null -and $_.NetAdapter.Status -ne "Disconnected"} | Select InterfaceAlias, IPv4Address 11 | InterfaceAlias IPv4Address 12 | -------------- ----------- 13 | Ethernet 1 {192.168.4.18} 14 | PS> New-VMSwitch -Name "bridge" -NetAdapterName "Ethernet 1" 15 | ``` 16 | - In powershell, create a virtual machine generation 2 (1vCPU, 1024MB RAM, 8GB disk space, bridge network connection, mount iso with Debian 12): 17 | ``` 18 | PS> New-VM -Name "export2garmin" -MemoryStartupBytes 1GB -Path "C:\" -NewVHDPath "C:\export2garmin\export2garmin.vhdx" -NewVHDSizeBytes 8GB -Generation 2 -SwitchName "bridge" 19 | PS> Add-VMDvdDrive -VMName "export2garmin" -Path "C:\Users\robert\Downloads\debian-12-amd64-netinst.iso" 20 | PS> Set-VMMemory "export2garmin" -DynamicMemoryEnabled $false 21 | PS> Set-VMFirmware "export2garmin" -EnableSecureBoot Off -BootOrder $(Get-VMDvdDrive -VMName "export2garmin"), $(Get-VMHardDiskDrive -VMName "export2garmin"), $(Get-VMNetworkAdapter -VMName "export2garmin") 22 | PS> Set-VM -Name "export2garmin" –AutomaticStartAction Start -AutomaticStopAction ShutDown 23 | ``` 24 | - Start Hyper-V console from powershell and install Debian 12 (default disk partitioning with minimal components, SSH server is enough): 25 | ``` 26 | PS> vmconnect.exe 192.168.4.18 export2garmin 27 | ``` 28 | - After installing system in powershell check IP address of guest to be able to log in easily via SSH, you will also need this address to configure connection parameters MQTT in ESP32 (**"mqtt_server"**): 29 | ``` 30 | PS> get-vm -Name "export2garmin" | Select -ExpandProperty Networkadapters | Select IPAddresses 31 | IPAddresses 32 | ----------- 33 | {192.168.4.118, fe80::215:5dff:fe04:c801} 34 | ``` 35 | 36 | ### 2.5.4. Preparing guest operating system 37 | - Log in via SSH with IP address (in this example 192.168.4.118) and install following package: 38 | ``` 39 | $ su - 40 | $ apt install -y sudo 41 | ``` 42 | - Add a user to sudo (in this example robert), reboot system: 43 | ``` 44 | $ usermod -aG sudo robert 45 | $ reboot 46 | ``` 47 | - Go to next part of instructions: 48 | - [Miscale - Debian 13](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_ESP32.md); 49 | - Back to [README](https://github.com/RobertWojtowicz/export2garmin/blob/master/README.md). 50 | 51 | ## If you like my work, you can buy me a coffee 52 | Buy Me A Coffee -------------------------------------------------------------------------------- /omron/deviceSpecific/hem-7600t.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import logging 4 | logger = logging.getLogger("omblepy") 5 | 6 | sys.path.append('..') 7 | from sharedDriver import sharedDeviceDriverCode 8 | 9 | class deviceSpecificDriver(sharedDeviceDriverCode): 10 | deviceEndianess = "big" 11 | userStartAdressesList = [0x02ac] 12 | perUserRecordsCountList = [100 ] 13 | recordByteSize = 0x0e 14 | transmissionBlockSize = 0x38 15 | 16 | settingsReadAddress = 0x0260 17 | settingsWriteAddress = 0x0286 18 | 19 | settingsUnreadRecordsBytes = [0x00, 0x08] 20 | settingsTimeSyncBytes = [0x14, 0x1e] 21 | 22 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 23 | recordDict = dict() 24 | recordDict["dia"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 0, 7) 25 | recordDict["sys"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 8, 15) + 25 26 | year = self._bytearrayBitsToInt(singleRecordAsByteArray, 16, 23) + 2000 27 | recordDict["bpm"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 24, 31) 28 | recordDict["mov"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 32, 32) 29 | recordDict["ihb"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 33, 33) 30 | month = self._bytearrayBitsToInt(singleRecordAsByteArray, 34, 37) 31 | day = self._bytearrayBitsToInt(singleRecordAsByteArray, 38, 42) 32 | hour = self._bytearrayBitsToInt(singleRecordAsByteArray, 43, 47) 33 | minute = self._bytearrayBitsToInt(singleRecordAsByteArray, 52, 57) 34 | second = self._bytearrayBitsToInt(singleRecordAsByteArray, 58, 63) 35 | second = min([second, 59]) #for some reason the second value can range up to 63 36 | recordDict["datetime"] = datetime.datetime(year, month, day, hour, minute, second) 37 | return recordDict 38 | 39 | def deviceSpecific_syncWithSystemTime(self): 40 | 41 | timeSyncSettingsCopy = self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] 42 | #read current time from cached settings bytes 43 | month, year, hour, day, second, minute = [int(byte) for byte in timeSyncSettingsCopy[2:8]] 44 | try: 45 | logger.info(f"device is set to date: {datetime.datetime(year + 2000, month, day, hour, minute, second).strftime('%Y-%m-%d %H:%M:%S')}") 46 | except: 47 | logger.warning(f"device is set to an invalid date") 48 | 49 | #write the current time into the cached settings which will be written later 50 | currentTime = datetime.datetime.now() 51 | setNewTimeDataBytes = timeSyncSettingsCopy[0:2] 52 | setNewTimeDataBytes += bytes([currentTime.month, currentTime.year - 2000, currentTime.hour, currentTime.day, currentTime.second, currentTime.minute]) 53 | setNewTimeDataBytes += bytes([0x00, sum(setNewTimeDataBytes) & 0xff]) #first byte does not seem to matter, second byte is crc generated by sum over data and only using lower 8 bits 54 | self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] = setNewTimeDataBytes 55 | 56 | logger.info(f"settings updated to new date {currentTime.strftime('%Y-%m-%d %H:%M:%S')}") 57 | return -------------------------------------------------------------------------------- /omron/omron_export.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import csv 5 | from datetime import datetime as dt 6 | from garminconnect import Garmin 7 | 8 | # Version info 9 | print(""" 10 | ============================================== 11 | Export 2 Garmin Connect v3.0 (omron_export.py) 12 | ============================================== 13 | """) 14 | 15 | # Importing user variables from a file 16 | path = os.path.dirname(os.path.dirname(__file__)) 17 | with open(path + '/user/export2garmin.cfg', 'r') as file: 18 | for line in file: 19 | line = line.strip() 20 | if line.startswith('omron_export_category'): 21 | name, value = line.split('=') 22 | globals()[name.strip()] = value.strip() 23 | 24 | # Import data variables from a file 25 | with open(path + '/user/omron_backup.csv', 'r') as csv_file: 26 | csv_reader = csv.reader(csv_file, delimiter=';') 27 | for row in csv_reader: 28 | if str(row[0]) in ["failed", "to_import"]: 29 | unixtime = int(row[1]) 30 | omrondate = str(row[2]) 31 | omrontime = str(row[3]) 32 | systolic = int(row[4]) 33 | diastolic = int(row[5]) 34 | pulse = int(row[6]) 35 | MOV = int(row[7]) 36 | IHB = int(row[8]) 37 | emailuser = str(row[9]) 38 | 39 | # Determine blood pressure category 40 | omron_export_category = str(omron_export_category) 41 | category = "None" 42 | if omron_export_category == 'eu': 43 | if systolic < 130 and diastolic < 85: 44 | category = "Normal" 45 | elif (130 <= systolic <= 139 and diastolic < 85) or (systolic < 130 and 85 <= diastolic <= 89): 46 | category = "High-Normal" 47 | elif (140 <= systolic <= 159 and diastolic < 90) or (systolic < 140 and 90 <= diastolic <= 99): 48 | category = "Grade_1" 49 | elif (160 <= systolic <= 179 and diastolic < 100) or (systolic < 160 and 100 <= diastolic <= 109): 50 | category = "Grade_2" 51 | elif omron_export_category == 'us': 52 | if systolic < 120 and diastolic < 80: 53 | category = "Normal" 54 | elif (120 <= systolic <= 129) and diastolic < 80: 55 | category = "High-Normal" 56 | elif (130 <= systolic <= 139) or (80 <= diastolic <= 89): 57 | category = "Grade_1" 58 | elif (systolic >= 140) or (diastolic >= 90): 59 | category = "Grade_2" 60 | 61 | # Print to temp.log file 62 | print(f"OMRON * Import data: {unixtime};{omrondate};{omrontime};{systolic:.0f};{diastolic:.0f};{pulse:.0f};{MOV:.0f};{IHB:.0f};{emailuser}") 63 | print(f"OMRON * Calculated data: {category};{MOV:.0f};{IHB:.0f};{emailuser};{dt.now().strftime('%d.%m.%Y;%H:%M')}") 64 | 65 | # Login to Garmin Connect 66 | with open(path + '/user/' + emailuser, 'r') as token_file: 67 | tokenstore = token_file.read() 68 | garmin = Garmin() 69 | garmin.login(tokenstore) 70 | 71 | # Upload data to Garmin Connect 72 | garmin.set_blood_pressure(timestamp=dt.fromtimestamp(unixtime).isoformat(),diastolic=diastolic,systolic=systolic,pulse=pulse) 73 | print("OMRON * Upload status: OK") 74 | break -------------------------------------------------------------------------------- /omron/deviceSpecific/hem-7322t.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import logging 4 | logger = logging.getLogger("omblepy") 5 | 6 | sys.path.append('..') 7 | from sharedDriver import sharedDeviceDriverCode 8 | 9 | class deviceSpecificDriver(sharedDeviceDriverCode): 10 | deviceEndianess = "big" 11 | userStartAdressesList = [0x02ac, 0x0824] 12 | perUserRecordsCountList = [100 , 100 ] 13 | recordByteSize = 0x0e 14 | transmissionBlockSize = 0x38 15 | 16 | settingsReadAddress = 0x0260 17 | settingsWriteAddress = 0x0286 18 | 19 | settingsUnreadRecordsBytes = [0x00, 0x08] 20 | settingsTimeSyncBytes = [0x14, 0x1e] 21 | 22 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 23 | recordDict = dict() 24 | recordDict["dia"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 0, 7) 25 | recordDict["sys"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 8, 15) + 25 26 | year = self._bytearrayBitsToInt(singleRecordAsByteArray, 16, 23) + 2000 27 | recordDict["bpm"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 24, 31) 28 | recordDict["mov"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 32, 32) 29 | recordDict["ihb"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 33, 33) 30 | month = self._bytearrayBitsToInt(singleRecordAsByteArray, 34, 37) 31 | day = self._bytearrayBitsToInt(singleRecordAsByteArray, 38, 42) 32 | hour = self._bytearrayBitsToInt(singleRecordAsByteArray, 43, 47) 33 | minute = self._bytearrayBitsToInt(singleRecordAsByteArray, 52, 57) 34 | second = self._bytearrayBitsToInt(singleRecordAsByteArray, 58, 63) 35 | second = min([second, 59]) #for some reason the second value can range up to 63 36 | recordDict["datetime"] = datetime.datetime(year, month, day, hour, minute, second) 37 | return recordDict 38 | 39 | def deviceSpecific_syncWithSystemTime(self): 40 | 41 | timeSyncSettingsCopy = self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] 42 | #read current time from cached settings bytes 43 | month, year, hour, day, second, minute = [int(byte) for byte in timeSyncSettingsCopy[2:8]] 44 | try: 45 | logger.info(f"device is set to date: {datetime.datetime(year + 2000, month, day, hour, minute, second).strftime('%Y-%m-%d %H:%M:%S')}") 46 | except: 47 | logger.warning(f"device is set to an invalid date") 48 | 49 | #write the current time into the cached settings which will be written later 50 | currentTime = datetime.datetime.now() 51 | setNewTimeDataBytes = timeSyncSettingsCopy[0:2] 52 | setNewTimeDataBytes += bytes([currentTime.month, currentTime.year - 2000, currentTime.hour, currentTime.day, currentTime.second, currentTime.minute]) 53 | setNewTimeDataBytes += bytes([0x00, sum(setNewTimeDataBytes) & 0xff]) #first byte does not seem to matter, second byte is crc generated by sum over data and only using lower 8 bits 54 | self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] = setNewTimeDataBytes 55 | 56 | logger.info(f"settings updated to new date {currentTime.strftime('%Y-%m-%d %H:%M:%S')}") 57 | return -------------------------------------------------------------------------------- /omron/deviceSpecific/hem-7150t.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import logging 4 | logger = logging.getLogger("omblepy") 5 | 6 | sys.path.append('..') 7 | from sharedDriver import sharedDeviceDriverCode 8 | 9 | class deviceSpecificDriver(sharedDeviceDriverCode): 10 | deviceEndianess = "little" 11 | userStartAdressesList = [0x0098] 12 | perUserRecordsCountList = [60] 13 | recordByteSize = 0x10 14 | transmissionBlockSize = 0x10 15 | 16 | settingsReadAddress = 0x0010 17 | settingsWriteAddress = 0x0054 18 | 19 | settingsUnreadRecordsBytes = [0x00, 0x10] 20 | settingsTimeSyncBytes = [0x2C, 0x3C] 21 | 22 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 23 | recordDict = dict() 24 | minute = self._bytearrayBitsToInt(singleRecordAsByteArray, 68, 73) 25 | second = self._bytearrayBitsToInt(singleRecordAsByteArray, 74, 79) 26 | second = min([second, 59]) #for some reason the second value can range up to 63 27 | recordDict["mov"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 80, 80) 28 | recordDict["ihb"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 81, 81) 29 | month = self._bytearrayBitsToInt(singleRecordAsByteArray, 82, 85) 30 | day = self._bytearrayBitsToInt(singleRecordAsByteArray, 86, 90) 31 | hour = self._bytearrayBitsToInt(singleRecordAsByteArray, 91, 95) 32 | year = self._bytearrayBitsToInt(singleRecordAsByteArray, 98, 103) + 2000 33 | recordDict["bpm"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 104, 111) 34 | recordDict["dia"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 112, 119) 35 | recordDict["sys"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 120, 127) + 25 36 | recordDict["datetime"] = datetime.datetime(year, month, day, hour, minute, second) 37 | return recordDict 38 | 39 | def deviceSpecific_syncWithSystemTime(self): 40 | timeSyncSettingsCopy = self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] 41 | #read current time from cached settings bytes 42 | year, month, day, hour, minute, second = [int(byte) for byte in timeSyncSettingsCopy[8:14]] 43 | try: 44 | logger.info(f"device is set to date: {datetime.datetime(year + 2000, month, day, hour, minute, second).strftime('%Y-%m-%d %H:%M:%S')}") 45 | except: 46 | logger.warning(f"device is set to an invalid date") 47 | 48 | #write the current time into the cached settings which will be written later 49 | currentTime = datetime.datetime.now() 50 | setNewTimeDataBytes = timeSyncSettingsCopy[0:8] #Take the first eight bytes from eeprom without modification 51 | setNewTimeDataBytes += bytes([currentTime.year - 2000, currentTime.month, currentTime.day, currentTime.hour, currentTime.minute, currentTime.second]) 52 | setNewTimeDataBytes += bytes([sum(setNewTimeDataBytes) & 0xff, 0x00]) #first byte does not seem to matter, second byte is crc generated by sum over data and only using lower 8 bits 53 | self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] = setNewTimeDataBytes 54 | 55 | logger.info(f"settings updated to new date {currentTime.strftime('%Y-%m-%d %H:%M:%S')}") 56 | return -------------------------------------------------------------------------------- /omron/deviceSpecific/hem-7342t.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import logging 4 | logger = logging.getLogger("omblepy") 5 | 6 | sys.path.append('..') 7 | from sharedDriver import sharedDeviceDriverCode 8 | 9 | class deviceSpecificDriver(sharedDeviceDriverCode): 10 | deviceEndianess = "little" 11 | userStartAdressesList = [0x0098, 0x06D8] 12 | perUserRecordsCountList = [100 , 100 ] 13 | recordByteSize = 0x10 14 | transmissionBlockSize = 0x10 15 | 16 | settingsReadAddress = 0x0010 17 | settingsWriteAddress = 0x0054 18 | 19 | settingsUnreadRecordsBytes = [0x00, 0x10] 20 | settingsTimeSyncBytes = [0x2C, 0x3C] 21 | 22 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 23 | recordDict = dict() 24 | minute = self._bytearrayBitsToInt(singleRecordAsByteArray, 68, 73) 25 | second = self._bytearrayBitsToInt(singleRecordAsByteArray, 74, 79) 26 | second = min([second, 59]) #for some reason the second value can range up to 63 27 | recordDict["mov"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 80, 80) 28 | recordDict["ihb"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 81, 81) 29 | month = self._bytearrayBitsToInt(singleRecordAsByteArray, 82, 85) 30 | day = self._bytearrayBitsToInt(singleRecordAsByteArray, 86, 90) 31 | hour = self._bytearrayBitsToInt(singleRecordAsByteArray, 91, 95) 32 | year = self._bytearrayBitsToInt(singleRecordAsByteArray, 98, 103) + 2000 33 | recordDict["bpm"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 104, 111) 34 | recordDict["dia"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 112, 119) 35 | recordDict["sys"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 120, 127) + 25 36 | recordDict["datetime"] = datetime.datetime(year, month, day, hour, minute, second) 37 | return recordDict 38 | 39 | def deviceSpecific_syncWithSystemTime(self): 40 | timeSyncSettingsCopy = self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] 41 | #read current time from cached settings bytes 42 | year, month, day, hour, minute, second = [int(byte) for byte in timeSyncSettingsCopy[8:14]] 43 | try: 44 | logger.info(f"device is set to date: {datetime.datetime(year + 2000, month, day, hour, minute, second).strftime('%Y-%m-%d %H:%M:%S')}") 45 | except: 46 | logger.warning(f"device is set to an invalid date") 47 | 48 | #write the current time into the cached settings which will be written later 49 | currentTime = datetime.datetime.now() 50 | setNewTimeDataBytes = timeSyncSettingsCopy[0:8] #Take the first eight bytes from eeprom without modification 51 | setNewTimeDataBytes += bytes([currentTime.year - 2000, currentTime.month, currentTime.day, currentTime.hour, currentTime.minute, currentTime.second]) 52 | setNewTimeDataBytes += bytes([sum(setNewTimeDataBytes) & 0xff, 0x00]) #first byte does not seem to matter, second byte is crc generated by sum over data and only using lower 8 bits 53 | self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] = setNewTimeDataBytes 54 | 55 | logger.info(f"settings updated to new date {currentTime.strftime('%Y-%m-%d %H:%M:%S')}") 56 | return -------------------------------------------------------------------------------- /omron/deviceSpecific/hem-7361t.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import logging 4 | logger = logging.getLogger("omblepy") 5 | 6 | sys.path.append('..') 7 | from sharedDriver import sharedDeviceDriverCode 8 | 9 | class deviceSpecificDriver(sharedDeviceDriverCode): 10 | deviceEndianess = "little" 11 | userStartAdressesList = [0x0098, 0x06D8] 12 | perUserRecordsCountList = [100 , 100 ] 13 | recordByteSize = 0x10 14 | transmissionBlockSize = 0x10 15 | 16 | settingsReadAddress = 0x0010 17 | settingsWriteAddress = 0x0054 18 | 19 | settingsUnreadRecordsBytes = [0x00, 0x10] 20 | settingsTimeSyncBytes = [0x2C, 0x3C] 21 | 22 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 23 | recordDict = dict() 24 | minute = self._bytearrayBitsToInt(singleRecordAsByteArray, 68, 73) 25 | second = self._bytearrayBitsToInt(singleRecordAsByteArray, 74, 79) 26 | second = min([second, 59]) #for some reason the second value can range up to 63 27 | recordDict["mov"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 80, 80) 28 | recordDict["ihb"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 81, 81) 29 | month = self._bytearrayBitsToInt(singleRecordAsByteArray, 82, 85) 30 | day = self._bytearrayBitsToInt(singleRecordAsByteArray, 86, 90) 31 | hour = self._bytearrayBitsToInt(singleRecordAsByteArray, 91, 95) 32 | year = self._bytearrayBitsToInt(singleRecordAsByteArray, 98, 103) + 2000 33 | recordDict["bpm"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 104, 111) 34 | recordDict["dia"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 112, 119) 35 | recordDict["sys"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 120, 127) + 25 36 | recordDict["datetime"] = datetime.datetime(year, month, day, hour, minute, second) 37 | return recordDict 38 | 39 | def deviceSpecific_syncWithSystemTime(self): 40 | timeSyncSettingsCopy = self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] 41 | #read current time from cached settings bytes 42 | year, month, day, hour, minute, second = [int(byte) for byte in timeSyncSettingsCopy[8:14]] 43 | try: 44 | logger.info(f"device is set to date: {datetime.datetime(year + 2000, month, day, hour, minute, second).strftime('%Y-%m-%d %H:%M:%S')}") 45 | except: 46 | logger.warning(f"device is set to an invalid date") 47 | 48 | #write the current time into the cached settings which will be written later 49 | currentTime = datetime.datetime.now() 50 | setNewTimeDataBytes = timeSyncSettingsCopy[0:8] #Take the first eight bytes from eeprom without modification 51 | setNewTimeDataBytes += bytes([currentTime.year - 2000, currentTime.month, currentTime.day, currentTime.hour, currentTime.minute, currentTime.second]) 52 | setNewTimeDataBytes += bytes([sum(setNewTimeDataBytes) & 0xff, 0x00]) #first byte does not seem to matter, second byte is crc generated by sum over data and only using lower 8 bits 53 | self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] = setNewTimeDataBytes 54 | 55 | logger.info(f"settings updated to new date {currentTime.strftime('%Y-%m-%d %H:%M:%S')}") 56 | return -------------------------------------------------------------------------------- /omron/deviceSpecific/hem-7155t.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import logging 4 | logger = logging.getLogger("omblepy") 5 | 6 | sys.path.append('..') 7 | from sharedDriver import sharedDeviceDriverCode 8 | 9 | class deviceSpecificDriver(sharedDeviceDriverCode): 10 | deviceEndianess = "little" 11 | userStartAdressesList = [0x0098, 0x0458] 12 | perUserRecordsCountList = [60 , 60 ] 13 | recordByteSize = 0x10 14 | transmissionBlockSize = 0x10 15 | 16 | settingsReadAddress = 0x0010 17 | settingsWriteAddress = 0x0054 18 | 19 | settingsUnreadRecordsBytes = [0x00, 0x10] 20 | settingsTimeSyncBytes = [0x2C, 0x3C] 21 | 22 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 23 | recordDict = dict() 24 | minute = self._bytearrayBitsToInt(singleRecordAsByteArray, 68, 73) 25 | second = self._bytearrayBitsToInt(singleRecordAsByteArray, 74, 79) 26 | second = min([second, 59]) #for some reason the second value can range up to 63 27 | recordDict["mov"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 80, 80) 28 | recordDict["ihb"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 81, 81) 29 | month = self._bytearrayBitsToInt(singleRecordAsByteArray, 82, 85) 30 | day = self._bytearrayBitsToInt(singleRecordAsByteArray, 86, 90) 31 | hour = self._bytearrayBitsToInt(singleRecordAsByteArray, 91, 95) 32 | year = self._bytearrayBitsToInt(singleRecordAsByteArray, 98, 103) + 2000 33 | recordDict["bpm"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 104, 111) 34 | recordDict["dia"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 112, 119) 35 | recordDict["sys"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 120, 127) + 25 36 | recordDict["datetime"] = datetime.datetime(year, month, day, hour, minute, second) 37 | return recordDict 38 | 39 | def deviceSpecific_syncWithSystemTime(self): 40 | timeSyncSettingsCopy = self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] 41 | #read current time from cached settings bytes 42 | year, month, day, hour, minute, second = [int(byte) for byte in timeSyncSettingsCopy[8:14]] 43 | try: 44 | logger.info(f"device is set to date: {datetime.datetime(year + 2000, month, day, hour, minute, second).strftime('%Y-%m-%d %H:%M:%S')}") 45 | except: 46 | logger.warning(f"device is set to an invalid date") 47 | 48 | #write the current time into the cached settings which will be written later 49 | currentTime = datetime.datetime.now() 50 | setNewTimeDataBytes = timeSyncSettingsCopy[0:8] #Take the first eight bytes from eeprom without modification 51 | setNewTimeDataBytes += bytes([currentTime.year - 2000, currentTime.month, currentTime.day, currentTime.hour, currentTime.minute, currentTime.second]) 52 | setNewTimeDataBytes += bytes([sum(setNewTimeDataBytes) & 0xff, 0x00]) #first byte does not seem to matter, second byte is crc generated by sum over data and only using lower 8 bits 53 | self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] = setNewTimeDataBytes 54 | 55 | logger.info(f"settings updated to new date {currentTime.strftime('%Y-%m-%d %H:%M:%S')}") 56 | return -------------------------------------------------------------------------------- /omron/deviceSpecific/hem-6232t.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import logging 4 | logger = logging.getLogger("omblepy") 5 | 6 | sys.path.append('..') 7 | from sharedDriver import sharedDeviceDriverCode 8 | 9 | class deviceSpecificDriver(sharedDeviceDriverCode): 10 | deviceEndianess = "big" 11 | userStartAdressesList = [0x2e8, 0x860] 12 | perUserRecordsCountList = [100 , 100 ] 13 | recordByteSize = 0x0e 14 | transmissionBlockSize = 0x38 15 | 16 | settingsReadAddress = 0x0260 17 | settingsWriteAddress = 0x02A4 18 | 19 | settingsUnreadRecordsBytes = [0x00, 0x08] 20 | settingsTimeSyncBytes = [0x14, 0x1e] #this is probably not correct 21 | 22 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 23 | recordDict = dict() 24 | recordDict["dia"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 0, 7) 25 | recordDict["sys"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 8, 15) + 25 26 | year = self._bytearrayBitsToInt(singleRecordAsByteArray, 18, 23) + 2000 27 | recordDict["bpm"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 24, 31) 28 | recordDict["ihb"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 32, 32) 29 | recordDict["mov"] = self._bytearrayBitsToInt(singleRecordAsByteArray, 33, 33) 30 | month = self._bytearrayBitsToInt(singleRecordAsByteArray, 34, 37) 31 | day = self._bytearrayBitsToInt(singleRecordAsByteArray, 38, 42) 32 | hour = self._bytearrayBitsToInt(singleRecordAsByteArray, 43, 47) 33 | minute = self._bytearrayBitsToInt(singleRecordAsByteArray, 52, 57) 34 | second = self._bytearrayBitsToInt(singleRecordAsByteArray, 58, 63) 35 | second = min([second, 59]) #for some reason the second value can range up to 63 36 | recordDict["datetime"] = datetime.datetime(year, month, day, hour, minute, second) 37 | return recordDict 38 | 39 | def deviceSpecific_syncWithSystemTime(self): 40 | raise ValueError("Not supported yet.") 41 | """ 42 | timeSyncSettingsCopy = self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] 43 | #read current time from cached settings bytes 44 | month, year, hour, day, second, minute = [int(byte) for byte in timeSyncSettingsCopy[2:8]] 45 | try: 46 | logger.info(f"device is set to date: {datetime.datetime(year + 2000, month, day, hour, minute, second).strftime('%Y-%m-%d %H:%M:%S')}") 47 | except: 48 | logger.warning(f"device is set to an invalid date") 49 | 50 | #write the current time into the cached settings which will be written later 51 | currentTime = datetime.datetime.now() 52 | setNewTimeDataBytes = timeSyncSettingsCopy[0:2] 53 | setNewTimeDataBytes += bytes([currentTime.month, currentTime.year - 2000, currentTime.hour, currentTime.day, currentTime.second, currentTime.minute]) 54 | setNewTimeDataBytes += bytes([0x00, sum(setNewTimeDataBytes) & 0xff]) #first byte does not seem to matter, second byte is crc generated by sum over data and only using lower 8 bits 55 | self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] = setNewTimeDataBytes 56 | 57 | logger.info(f"settings updated to new date {currentTime.strftime('%Y-%m-%d %H:%M:%S')}") 58 | return 59 | """ 60 | -------------------------------------------------------------------------------- /miscale/miscale_export.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import csv 5 | import Xiaomi_Scale_Body_Metrics 6 | from datetime import datetime as dt, date 7 | from garminconnect import Garmin 8 | 9 | # Version info 10 | print(""" 11 | ================================================ 12 | Export 2 Garmin Connect v3.5 (miscale_export.py) 13 | ================================================ 14 | """) 15 | 16 | class User(): 17 | def __init__(self, sex, height, birthdate, email, max_weight, min_weight): 18 | self.sex = sex 19 | self.height = height 20 | self.birthdate = birthdate 21 | self.email = email 22 | self.max_weight = max_weight 23 | self.min_weight = min_weight 24 | 25 | # Calculating age 26 | @property 27 | def age(self): 28 | today = date.today() 29 | calc_date = dt.strptime(self.birthdate, "%d-%m-%Y") 30 | return today.year - calc_date.year 31 | 32 | # Importing user variables from a file 33 | path = os.path.dirname(os.path.dirname(__file__)) 34 | users = [] 35 | with open(path + '/user/export2garmin.cfg', 'r') as file: 36 | for line in file: 37 | line = line.strip() 38 | if line.startswith('miscale_export_'): 39 | user_data = eval(line.split('=')[1].strip()) 40 | users.append(User(*user_data)) 41 | if line.startswith('s400_pulse'): 42 | name, value = line.split('=') 43 | globals()[name.strip()] = value.strip() 44 | 45 | # Import data variables from a file 46 | with open(path + '/user/miscale_backup.csv', 'r') as csv_file: 47 | csv_reader = csv.reader(csv_file, delimiter=';') 48 | for row in csv_reader: 49 | if str(row[0]) in ["failed", "to_import"]: 50 | mitdatetime = int(row[1]) 51 | weight = float(row[2]) 52 | miimpedance = float(row[3]) 53 | if s400_pulse == 'on': 54 | diastolic = int(30) 55 | systolic = int(40) 56 | pulse = int(row[5]) 57 | break 58 | 59 | # Matching Garmin Connect account to weight 60 | selected_user = None 61 | for user in users: 62 | if user.min_weight <= weight <= user.max_weight: 63 | selected_user = user 64 | break 65 | 66 | # Calcuating body metrics 67 | if selected_user is not None and 'email@email.com' not in selected_user.email: 68 | lib = Xiaomi_Scale_Body_Metrics.bodyMetrics(weight, selected_user.height, selected_user.age, selected_user.sex, int(miimpedance)) 69 | bmi = lib.getBMI() 70 | percent_fat = lib.getFatPercentage() 71 | muscle_mass = lib.getMuscleMass() 72 | bone_mass = lib.getBoneMass() 73 | percent_hydration = lib.getWaterPercentage() 74 | physique_rating = lib.getBodyType() 75 | visceral_fat_rating = lib.getVisceralFat() 76 | metabolic_age = lib.getMetabolicAge() 77 | basal_met = lib.getBMR() 78 | 79 | # Print to temp.log file 80 | formatted_time = dt.fromtimestamp(mitdatetime).strftime("%d.%m.%Y;%H:%M") 81 | print(f"MISCALE * Import data: {mitdatetime};{weight:.1f};{miimpedance:.0f}") 82 | print(f"MISCALE * Calculated data: {formatted_time};{weight:.1f};{bmi:.1f};{percent_fat:.1f};{muscle_mass:.1f};{bone_mass:.1f};{percent_hydration:.1f};{physique_rating:.0f};{visceral_fat_rating:.0f};{metabolic_age:.0f};{basal_met:.0f};{lib.getLBMCoefficient():.1f};{lib.getIdealWeight():.1f};{lib.getFatMassToIdeal()};{lib.getProteinPercentage():.1f};{miimpedance:.0f};{selected_user.email};{dt.now().strftime('%d.%m.%Y;%H:%M')}") 83 | 84 | # Login to Garmin Connect 85 | with open(path + '/user/' + selected_user.email, 'r') as token_file: 86 | tokenstore = token_file.read() 87 | garmin = Garmin() 88 | garmin.login(tokenstore) 89 | 90 | # Upload data to Garmin Connect 91 | garmin.add_body_composition(timestamp=dt.fromtimestamp(mitdatetime).isoformat(),weight=weight,bmi=bmi,percent_fat=percent_fat,muscle_mass=muscle_mass,bone_mass=bone_mass,percent_hydration=percent_hydration,physique_rating=physique_rating,visceral_fat_rating=visceral_fat_rating,metabolic_age=metabolic_age,basal_met=basal_met) 92 | if s400_pulse == 'on': 93 | garmin.set_blood_pressure(timestamp=dt.fromtimestamp(mitdatetime).isoformat(),diastolic=diastolic,systolic=systolic,pulse=pulse) 94 | print("MISCALE * Upload status: OK") 95 | else: 96 | 97 | # Print to temp.log file 98 | print(f"MISCALE * Import data: {mitdatetime};{weight:.1f};{miimpedance:.0f}") 99 | print("MISCALE * There is no user with given weight or undefined user email@email.com, check users section in export2garmin.cfg") -------------------------------------------------------------------------------- /manuals/all_BLE_win.md: -------------------------------------------------------------------------------- 1 | ## 2.5. all_BLE_WIN VERSION 2 | - This module is based on following projects: 3 | - https://github.com/dorssel/usbipd-win. 4 | 5 | ### 2.5.1. Preparing host operating system 6 | - It is possible to run Linux as a virtual machine in Windows 11 by installing Hyper-V with powershell: 7 | ``` 8 | PS> Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All 9 | ``` 10 | - After rebooting Windows, install software to connect usb to virtual machine, type command in powershell: 11 | ``` 12 | PS> winget install usbipd 13 | ``` 14 | - Describing solution will **only work with USB adapters**, purchase a low-cost bluetooth adapter from support matrix [2.6.1.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/matrix_BLE.md) 15 | - List devices in powershell that you can share: 16 | ``` 17 | PS> usbipd list 18 | Connected: 19 | BUSID VID:PID DEVICE STATE 20 | 1-2 0a12:0001 Generic Bluetooth Radio Not shared 21 | ``` 22 | - Share USB bluetooth adapter for virtual machine (requires administrator privileges): 23 | ``` 24 | PS> usbipd bind --busid 1-2 25 | ``` 26 | - Disable shared device (in this example Generic Bluetooth Adapter) from host system so that there are no bluetooth adapter conflicts, check IP address (network adapter with default gateway): 27 | ``` 28 | PS> Get-PnpDevice -FriendlyName "Generic Bluetooth Radio" | Disable-PnpDevice -Confirm:$false 29 | PS> Get-NetIPConfiguration | Where-Object {$_.IPv4DefaultGateway -ne $null -and $_.NetAdapter.Status -ne "Disconnected"} | Select InterfaceAlias, IPv4Address 30 | InterfaceAlias IPv4Address 31 | -------------- ----------- 32 | Ethernet 1 {192.168.4.18} 33 | ``` 34 | - In powershell, create a virtual machine generation 2 (1vCPU, 1024MB RAM, 8GB disk space, default NAT network connection, mount iso with Debian 13): 35 | ``` 36 | PS> New-VM -Name "export2garmin" -MemoryStartupBytes 1GB -Path "C:\" -NewVHDPath "C:\export2garmin\export2garmin.vhdx" -NewVHDSizeBytes 8GB -Generation 2 -SwitchName "Default Switch" 37 | PS> Add-VMDvdDrive -VMName "export2garmin" -Path "C:\Users\robert\Downloads\debian-12-amd64-netinst.iso" 38 | PS> Set-VMMemory "export2garmin" -DynamicMemoryEnabled $false 39 | PS> Set-VMFirmware "export2garmin" -EnableSecureBoot Off -BootOrder $(Get-VMDvdDrive -VMName "export2garmin"), $(Get-VMHardDiskDrive -VMName "export2garmin"), $(Get-VMNetworkAdapter -VMName "export2garmin") 40 | PS> Set-VM -Name "export2garmin" –AutomaticStartAction Start -AutomaticStopAction ShutDown 41 | ``` 42 | - Start Hyper-V console from powershell and install Debian 13 (default disk partitioning with minimal components, SSH server is enough): 43 | ``` 44 | PS> vmconnect.exe 192.168.4.18 export2garmin 45 | ``` 46 | - After installing system in powershell check IP address of guest to be able to log in easily via SSH: 47 | ``` 48 | PS> get-vm -Name "export2garmin" | Select -ExpandProperty Networkadapters | Select IPAddresses 49 | IPAddresses 50 | ----------- 51 | {172.17.76.18, fe80::215:5dff:fe04:c801} 52 | ``` 53 | 54 | ### 2.5.2. Preparing guest operating system 55 | - Log in via SSH with IP address (in this example 172.17.76.18) and install following packages: 56 | ``` 57 | $ su - 58 | $ apt install -y usbutils usbip sudo 59 | ``` 60 | - Add a user to sudo, add loading vhci_hcd module on boot, reboot system: 61 | ``` 62 | $ usermod -aG sudo robert 63 | $ echo "vhci_hcd" | sudo tee -a /etc/modules 64 | $ reboot 65 | ``` 66 | - Set USB Bluetooth adapter service to start automatically (via host IP address, include "User" name) at system boot, create a file `sudo nano /etc/systemd/system/usbip-attach.service`: 67 | ``` 68 | [Unit] 69 | Description=usbip attach service 70 | After=network.target 71 | 72 | [Service] 73 | Type=simple 74 | User=robert 75 | ExecStart=/usr/sbin/usbip attach --remote=192.168.4.18 --busid=1-2 76 | ExecStop=/usr/sbin/usbip detach --port=0 77 | RemainAfterExit=yes 78 | Restart=on-failure 79 | 80 | [Install] 81 | WantedBy=multi-user.target 82 | ``` 83 | - Activate usbip-attach service and run it: 84 | ``` 85 | $ sudo systemctl enable usbip-attach.service && sudo systemctl start usbip-attach.service 86 | ``` 87 | - You can check if export2garmin service works `sudo systemctl status usbip-attach.service` or temporarily stop it with command `sudo systemctl stop usbip-attach.service`; 88 | - Go to next part of instructions, select module: 89 | - [Miscale | Mi Body Composition Scale 2 - Debian 13](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_BLE.md); 90 | - [Miscale | Xiaomi Body Composition Scale S400 - Debian 13](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/S400_BLE.md); 91 | - [Omron - Debian 13](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Omron_BLE.md); 92 | - Back to [README](https://github.com/RobertWojtowicz/export2garmin/blob/master/README.md). 93 | 94 | ## If you like my work, you can buy me a coffee 95 | Buy Me A Coffee -------------------------------------------------------------------------------- /user/export2garmin.cfg: -------------------------------------------------------------------------------- 1 | # ========================================================================= 2 | # Export 2 Garmin Connect v3.5 (export2garmin.cfg) main options: 3 | # ========================================================================= 4 | 5 | # Watchdog for WiFi connection. Allowed switch parameter is "off" or "on" 6 | switch_wifi_watchdog=off 7 | 8 | # Path to temp files, default is /dev/shm 9 | switch_temp_path=/dev/shm 10 | 11 | # Skip BLE scanning and send only data from _backup.csv file set to "off". Allowed switch parameter is "off" or "on" 12 | switch_bt=on 13 | 14 | # HCI number assigned to BLE adapter, default is 0 (hci0) 15 | ble_arg_hci=0 16 | 17 | # Enabling BLE adapter search by MAC address instead of HCI number. Allowed switch parameter is "off" or "on" 18 | ble_arg_hci2mac=off 19 | 20 | # If you set the above parameter to "on", enter BLE adapter MAC adress, please use uppercase letters 21 | ble_arg_mac=00:00:00:00:00:00 22 | 23 | # BLE adpater scan time in seconds, default is 10 24 | ble_adapter_time=10 25 | 26 | # Enabling checking whether any BLE devices have been detected. Allowed switch parameter is "off" or "on" 27 | ble_adapter_check=off 28 | 29 | # If you set the above parameter to "on", set number of attempts, default is 7 30 | ble_adapter_repeat=7 31 | 32 | # For Chinese users change value to "True", default is "False" 33 | tokens_is_cn=False 34 | 35 | 36 | # ========================================================================= 37 | # Mi Body Composition Scale 2 & Xiaomi Body Composition Scale S400 options: 38 | # ========================================================================= 39 | 40 | # If you are using a BLE adapter enter scale MAC adress, please use uppercase letters 41 | ble_miscale_mac=00:00:00:00:00:00 42 | 43 | # Adding all users in following format (sex, height in cm, birthdate in dd-mm-yyyy, email to Garmin Connect, max_weight in kg, min_weight in kg) 44 | miscale_export_user1=("male", 172, "02-04-1984", "email@email.com", 65, 53) 45 | miscale_export_user2=("male", 188, "02-04-1984", "email@email.com", 92, 85) 46 | 47 | 48 | # ========================================================================= 49 | # Mi Body Composition Scale 2 options: 50 | # ========================================================================= 51 | 52 | # Enabling Mi scale synchronization. Allowed switch parameter is "off" or "on" 53 | switch_miscale=off 54 | 55 | # Time offset parameter in seconds, default is 0. Change to e.g. -3600 or 3600 56 | miscale_time_offset=0 57 | 58 | # Protection against unsynchronization of scale time. Time shift parameter in seconds, default is 1200 59 | miscale_time_unsync=1200 60 | 61 | # Protection against duplicates. Difference between weighting in seconds, default is 30 62 | miscale_time_check=30 63 | 64 | # Parameters for MQTT broker, skip if you are not using. Allowed switch parameter is "off" or "on" 65 | switch_mqtt=off 66 | miscale_mqtt_passwd=password 67 | miscale_mqtt_user=admin 68 | 69 | 70 | # ========================================================================= 71 | # Xiaomi Body Composition Scale S400 options: 72 | # ========================================================================= 73 | 74 | # Enabling Xiaomi scale synchronization. Allowed switch parameter is "off" or "on" 75 | switch_s400=off 76 | 77 | # Paste BLE KEY from Xiaomi Cloud Tokens Extractor project 78 | ble_miscale_key=00000000000000000000000000000000 79 | 80 | # Switch to a separate process and hci. Allowed switch parameter is "off" or "on" 81 | switch_s400_hci=off 82 | 83 | # HCI number assigned to BLE adapter, default is 0 (hci0) 84 | s400_arg_hci=0 85 | 86 | # Enabling BLE adapter search by MAC address instead of HCI number. Allowed switch parameter is "off" or "on" 87 | s400_arg_hci2mac=off 88 | 89 | # If you set the above parameter to "on", enter BLE adapter MAC adress, please use uppercase letters 90 | s400_arg_mac=00:00:00:00:00:00 91 | 92 | # Optional heart rate upload to blood pressure section. Allowed switch parameter is "off" or "on" 93 | s400_pulse=off 94 | 95 | 96 | # ========================================================================= 97 | # Omron Blood Pressure options: 98 | # ========================================================================= 99 | 100 | # Enabling Omron synchronization. Allowed switch parameter is "off" or "on" 101 | switch_omron=off 102 | 103 | # Enter Omron model, replace "hem-xxxxt" entry. Allowed parameter is "hem-6232t", "hem-7150t", "hem-7155t", "hem-7322t", "hem-7342t", "hem-7361t", "hem-7530t", "hem-7600t" 104 | omron_omblepy_model=hem-xxxxt 105 | 106 | # Enter Omron MAC adress, please use uppercase letters 107 | omron_omblepy_mac=00:00:00:00:00:00 108 | 109 | # BLE adpater scan time in seconds, default is 10 110 | omron_omblepy_time=10 111 | 112 | # Enabling debug omblepy. Allowed parameter is "off" or "on" 113 | omron_omblepy_debug=off 114 | 115 | # Enabling downloading all records, recommended only one-time import. Allowed parameter is "off" or "on" 116 | omron_omblepy_all=off 117 | 118 | # Adding max 2 users in following format (email to Garmin Connect) 119 | omron_export_user1=email@email.com 120 | omron_export_user2=email@email.com 121 | 122 | # Choose blood pressure category classification by country in omron_backup.csv file. Allowed switch parameter is "eu" or "us" 123 | omron_export_category=eu -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | rkwojtowicz@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /manuals/about_BLE.md: -------------------------------------------------------------------------------- 1 | ## 2.6. about_BLE VERSION 2 | 3 | ### 2.6.1. BLE adapters support matrix 4 | | BT version | Chipset/Brand | Alterative name/Model | Type | Range | External antenna | Mi Body Composition Scale 2 | Xiaomi Body Composition Scale S400 | Omron | Testers | 5 | | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ----- | 6 | | 4.0 | CSR8510 A10/LogiLink | Cambridge Silicon Radio | USB | medium | ❌ | ✔️ | ✔️ | ✔️ | RobertWojtowicz | 7 | | 4.1 | Broadcom/Raspberry Pi | Zero W | Internal | low | ❌ | ❌ | ❌ | ❌ | RobertWojtowicz | 8 | | 4.2 | Broadcom/Raspberry Pi | Zero 2W | Internal | low | ❌ | ✔️ | ❌ | ✔️ | RobertWojtowicz | 9 | | 5.0 | Broadcom/Raspberry Pi | 4B** | Internal | low | ❌ | ✔️ | ❌ | ✔️ | RobertWojtowicz | 10 | | 5.0 | Broadcom/Raspberry Pi | 5**(*) | Internal | low | ❌ | ✔️ | ❌ | ✔️ | RobertWojtowicz | 11 | | 5.1 | RTL8761B/Zexmte| Realtek | USB | high* | ✔️* | ✔️| ❌ | ✔️ | RobertWojtowicz | 12 | | 5.3 | ATS2851/Zexmte | Actions | USB | high* | ✔️* | ✔️| ❓ | ❌ | RobertWojtowicz | 13 | | 5.4 | RDK | X5 | Internal | low | ❌ | ❓ | ✔️ | ❓ | CoreJa | 14 | 15 | ✔️=tested working, ❓=not tested, ❌=not supported 16 | 17 | ### 2.6.2. Troubleshooting BLE adapters 18 | - (*) Bluetooth adapter should have a removable RP-SMA antenna if you want a long range; 19 | - ATS2851 chipset has native support in Debian 13 operating system | Raspberry Pi OS no additional driver needed; 20 | - If you have a lot of bluetooth devices in area, it's a good idea to set an additional check, set ble_adapter_check parameter to "on" in `user/export2garmin.cfg`; 21 | - Script `miscale/miscale_ble.py` has implemented debug mode and recovery mechanisms for bluetooth, you can verify if everything is working properly; 22 | - If you are using a virtual machine, assign bluetooth adapter from tab Hardware > Add: USB device > Use USB Vendor/Device ID > Choose Device: > Passthrough a specific device (tested on Proxmox VE 8-9); 23 | - RTL8761B chipset requires driver (for Raspberry Pi OS skip this step), install Realtek package and restart virtual machine: 24 | ``` 25 | sudo apt install -y firmware-realtek 26 | sudo reboot 27 | ``` 28 | - In some cases of **Raspberry Pi** when using internal bluetooth and WiFi: 29 | - (**) You should connect WiFi on 5GHz, because on 2,4GHz there may be a problem with connection stability (sharing same antenna); 30 | - (***) WiFi may freeze in 5 version, set switch_wifi_watchdog parameter to "on" in `user/export2garmin.cfg`; 31 | - If you only use an external BLE adapter, it is recommended to disable internal module `sudo nano /boot/firmware/config.txt` and restart: 32 | ``` 33 | [all] 34 | dtoverlay=disable-bt 35 | ``` 36 | ``` 37 | sudo reboot 38 | ``` 39 | 40 | ### 2.6.3. Using multiple BLE adapters 41 | - If you are using multiple BLE adapters, select appropriate one by HCI number or MAC address (recommended) and set in `user/export2garmin.cfg` file; 42 | - Use command `sudo hciconfig -a` to locate BLE adapter, and then select type of identification: 43 | - By HCI number, set parameter "ble_arg_hci"; 44 | - By MAC address, set parameter "ble_arg_hci2mac" to "on" and specify MAC addres in parameter "ble_arg_mac". 45 | - If you are using Omron integration, you must re-pair device with new BLE adapter, see section [2.4.3.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Omron_BLE.md#243-configuring-scripts) 46 | 47 | ### 2.6.4. Using two BLE adapters in parallel 48 | - First, you need to complete installation steps for Omron and Xiaomi Body Composition Scale S400; 49 | - Miscale and Omron modules can be activated individually or run together: 50 | - Devices can run together (Mi Body Composition Scale 2 and Omron without any changes); 51 | - Devices can run together but there must be a sequence, first measuring blood pressure and then weighing (Xiaomi Body Composition Scale S400 and Omron);
52 | This is because Xiaomi Body Composition Scale S400 requires continuous scanning, importing data from scale will allow you to go to Omron module. 53 | - Devices can run together in parallel but two USB Bluetooth adapters are required, one for scanning Xiaomi Body Composition Scale S400 and other for Omron; 54 | - In this last case, set parameter switch_s400_hci to "on" in `user/export2garmin.cfg`; 55 | - Use command `sudo hciconfig -a` to locate BLE adapter for Xiaomi Body Composition Scale, and then select type of identification: 56 | - By HCI number, set parameter "s400_arg_hci"; 57 | - By MAC address, set parameter "s400_arg_hci2mac" to "on" and specify MAC addres in parameter "s400_arg_mac". 58 | - From this point on, Xiaomi Body Composition Scale S400 data scanning is done in a separate process and individuall BLE adapter: 59 | ``` 60 | $ /home/robert/export2garmin-master/import_data.sh 61 | 62 | ============================================= 63 | Export 2 Garmin Connect v3.6 (import_data.sh) 64 | ============================================= 65 | 66 | 05.08.2025-10:21:19 SYSTEM * Main process runs on PID: 000 67 | 05.08.2025-10:21:19 SYSTEM * Path to temp files: /dev/shm/ 68 | 05.08.2025-10:21:19 SYSTEM * Path to user files: /home/robert/export2garmin-master/user/ 69 | 05.08.2025-10:21:19 SYSTEM * BLE adapter is ON in export2garmin.cfg file, check if available 70 | 05.08.2025-10:21:22 SYSTEM * BLE adapter hci1(00:00:00:00:00:00) working, check if temp.log file exists 71 | 05.08.2025-10:21:22 SYSTEM * temp.log file exists, go to modules 72 | 05.08.2025-10:21:22 MISCALE|S400 * Module is ON in export2garmin.cfg file 73 | 05.08.2025-10:21:22 MISCALE|S400 * miscale_backup.csv file exists, checking for new data 74 | 05.08.2025-10:21:22 MISCALE|S400 * There is no new data to upload to Garmin Connect 75 | 05.08.2025-10:21:22 S400 * A separate BLE adapter is ON in export2garmin.cfg file, check if available 76 | 05.08.2025-10:21:22 OMRON * Module is ON in export2garmin.cfg file 77 | 05.08.2025-10:21:22 OMRON * omron_backup.csv file exists, checking for new data 78 | 05.08.2025-10:21:22 OMRON * Importing data from a BLE adapter 79 | 05.08.2025-10:21:22 S400 * BLE adapter hci0(00:00:00:00:00:00) working, importing data from a BLE adapter 80 | ``` 81 | - Go to next part of instructions, select module: 82 | - [Miscale | Mi Body Composition Scale 2 - Debian 13](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_BLE.md); 83 | - [Miscale | Xiaomi Body Composition Scale S400 - Debian 13](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/S400_BLE.md); 84 | - [Omron - Debian 13](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Omron_BLE.md); 85 | - Back to [README](https://github.com/RobertWojtowicz/export2garmin/blob/master/README.md). 86 | 87 | ## If you like my work, you can buy me a coffee 88 | Buy Me A Coffee -------------------------------------------------------------------------------- /miscale/s400_ble.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import subprocess 5 | import signal 6 | import argparse 7 | import asyncio 8 | import time 9 | import logging 10 | from datetime import datetime 11 | from bleak import BleakScanner 12 | from xiaomi_ble.parser import XiaomiBluetoothDeviceData 13 | from bluetooth_sensor_state_data import BluetoothServiceInfo 14 | 15 | # Handling print function in BrokenPipeError exception 16 | signal.signal(signal.SIGPIPE, signal.SIG_DFL) 17 | time_print = time.time() 18 | def safe_print(*args, **kwargs): 19 | global time_print 20 | try: 21 | print(*args, **kwargs) 22 | time_print = time.time() 23 | except BrokenPipeError: 24 | signal.raise_signal(signal.SIGPIPE) 25 | 26 | safe_print(""" 27 | ========================================== 28 | Export 2 Garmin Connect v3.6 (s400_ble.py) 29 | ========================================== 30 | """) 31 | 32 | # Importing bluetooth variables from a file 33 | path = os.path.dirname(os.path.dirname(__file__)) 34 | with open(path + '/user/export2garmin.cfg', 'r') as file: 35 | for line in file: 36 | line = line.strip() 37 | if line.startswith('ble_miscale_') or line.startswith('ble_arg_hci'): 38 | name, value = line.split('=') 39 | globals()[name.strip()] = value.strip() 40 | 41 | # Arguments to pass in script 42 | parser = argparse.ArgumentParser() 43 | parser.add_argument("-a", default=ble_arg_hci) 44 | args = parser.parse_args() 45 | ble_arg_hci = args.a 46 | hci_out = subprocess.check_output(["hcitool", "dev"], stderr=subprocess.DEVNULL).decode() 47 | ble_arg_mac = [line.split()[1] for line in hci_out.splitlines() if f"hci{ble_arg_hci}" in line][0] 48 | 49 | # Counters for slow/fast iteration detection 50 | time_slow = 0 51 | time_fast = 0 52 | def handle_timing(duration): 53 | global time_slow, time_fast 54 | if duration > 1: 55 | safe_print(f" BLE scan iteration took {duration:.2f} s, too slow") 56 | time_slow += 1 57 | time_fast = 0 58 | if time_slow >= 5: 59 | safe_print(f"{datetime.now().strftime('%d.%m.%Y-%H:%M:%S')} S400 * Reading BLE data failed, finished BLE scan") 60 | stop_event.set() 61 | else: 62 | time_fast += 1 63 | if time_fast >= 5 and time_slow < 5: 64 | time_slow = 0 65 | time_fast = 0 66 | 67 | # Detecting an incorrect BLE KEY 68 | ble_key = bytes.fromhex(ble_miscale_key) 69 | xiaomi_parser = XiaomiBluetoothDeviceData(bindkey=ble_key) 70 | logger = logging.getLogger("xiaomi_ble.parser") 71 | logger.setLevel(logging.DEBUG) 72 | time_log = 0 73 | stop_event = asyncio.Event() 74 | class DecryptionFailedHandler(logging.Handler): 75 | def emit(self, record): 76 | global time_log 77 | msg = record.getMessage() 78 | if "Decryption failed" in msg: 79 | now = time.time() 80 | if now - time_log > 1: 81 | safe_print(f"{datetime.now().strftime('%d.%m.%Y-%H:%M:%S')} S400 * Decryption failed, finished BLE scan") 82 | time_log = now 83 | stop_event.set() 84 | handler = DecryptionFailedHandler() 85 | logger.addHandler(handler) 86 | 87 | # Reading data from a scale using a BLE adapter 88 | time_found = 0 89 | mac_seen_event = asyncio.Event() 90 | def detection_callback(device, advertisement_data): 91 | try: 92 | global time_found 93 | if device.address.upper() != ble_miscale_mac.upper(): 94 | return 95 | safe_print(f" BLE device found with address: {device.address.upper()}") 96 | now = time.monotonic() 97 | if time_found == 0: 98 | time_found = now 99 | else: 100 | duration = now - time_found 101 | handle_timing(duration) 102 | time_found = now 103 | mac_seen_event.set() 104 | service_info = BluetoothServiceInfo(name=device.name,address=device.address,rssi=advertisement_data.rssi,manufacturer_data=advertisement_data.manufacturer_data,service_data=advertisement_data.service_data,service_uuids=advertisement_data.service_uuids,source=device.address) 105 | if xiaomi_parser.supported(service_info): 106 | update = xiaomi_parser.update(service_info) 107 | if update and update.entity_values: 108 | fields = {'Mass','Impedance','Impedance Low','Heart Rate'} 109 | values = {v.name: v.native_value for v in update.entity_values.values() if v.name in fields} 110 | if fields <= values.keys(): 111 | safe_print(f"{datetime.now().strftime('%d.%m.%Y-%H:%M:%S')} S400 * Reading BLE data complete, finished BLE scan") 112 | safe_print(f"to_import;{int(time.time())};{values['Mass']};{values['Impedance']:.0f};{values['Impedance Low']:.0f};{values['Heart Rate']}") 113 | stop_event.set() 114 | except Exception: 115 | pass 116 | 117 | # Watchdog for entire loop 118 | async def watchdog(timeout=30): 119 | while not stop_event.is_set(): 120 | await asyncio.sleep(1) 121 | if time.time() - time_print > timeout: 122 | safe_print(f"{datetime.now().strftime('%d.%m.%Y-%H:%M:%S')} S400 * Reading BLE data failed, finished BLE scan") 123 | stop_event.set() 124 | 125 | # Searching for scale, 5 attempts 126 | time_not_found = 0 127 | async def main(): 128 | global time_not_found 129 | asyncio.create_task(watchdog(30)) 130 | safe_print(f"{datetime.now().strftime('%d.%m.%Y-%H:%M:%S')} * Starting scan with BLE adapter hci{ble_arg_hci}({ble_arg_mac}):") 131 | attempts = 0 132 | while attempts < 5 and not stop_event.is_set(): 133 | mac_seen_event.clear() 134 | scanner = BleakScanner(detection_callback=detection_callback) 135 | scan_started = False 136 | try: 137 | await scanner.start() 138 | scan_started = True 139 | await asyncio.wait_for(mac_seen_event.wait(), timeout=0.5) 140 | await stop_event.wait() 141 | break 142 | except asyncio.TimeoutError: 143 | safe_print(f" BLE device not found with address: {ble_miscale_mac.upper()}") 144 | now = time.monotonic() 145 | if time_not_found == 0: 146 | time_not_found = now 147 | else: 148 | duration = now - time_not_found 149 | handle_timing(duration) 150 | time_not_found = now 151 | attempts += 1 152 | finally: 153 | if scan_started: 154 | try: 155 | await scanner.stop() 156 | except Exception: 157 | pass 158 | if not stop_event.is_set(): 159 | safe_print(f"{datetime.now().strftime('%d.%m.%Y-%H:%M:%S')} S400 * Reading BLE data failed, finished BLE scan") 160 | 161 | # Main program loop 162 | if __name__ == "__main__": 163 | asyncio.run(main()) -------------------------------------------------------------------------------- /miscale/body_scales.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | class bodyScales: 4 | def __init__(self, age, height, sex, weight, scaleType='xiaomi'): 5 | self.age = age 6 | self.height = height 7 | self.sex = sex 8 | self.weight = weight 9 | 10 | if scaleType == 'xiaomi': 11 | self.scaleType = 'xiaomi' 12 | else: 13 | self.scaleType = 'holtek' 14 | 15 | # Get BMI scale 16 | def getBMIScale(self): 17 | if self.scaleType == 'xiaomi': 18 | # Amazfit/new mi fit 19 | #return [18.5, 24, 28] 20 | # Old mi fit // amazfit for body figure 21 | return [18.5, 25.0, 28.0, 32.0] 22 | elif self.scaleType == 'holtek': 23 | return [18.5, 25.0, 30.0] 24 | 25 | # Get fat percentage scale 26 | def getFatPercentageScale(self): 27 | # The included tables where quite strange, maybe bogus, replaced them with better ones... 28 | if self.scaleType == 'xiaomi': 29 | scales = [ 30 | {'min': 0, 'max': 12, 'female': [12.0, 21.0, 30.0, 34.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 31 | {'min': 12, 'max': 14, 'female': [15.0, 24.0, 33.0, 37.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 32 | {'min': 14, 'max': 16, 'female': [18.0, 27.0, 36.0, 40.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 33 | {'min': 16, 'max': 18, 'female': [20.0, 28.0, 37.0, 41.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 34 | {'min': 18, 'max': 40, 'female': [21.0, 28.0, 35.0, 40.0], 'male': [11.0, 17.0, 22.0, 27.0]}, 35 | {'min': 40, 'max': 60, 'female': [22.0, 29.0, 36.0, 41.0], 'male': [12.0, 18.0, 23.0, 28.0]}, 36 | {'min': 60, 'max': 100, 'female': [23.0, 30.0, 37.0, 42.0], 'male': [14.0, 20.0, 25.0, 30.0]}, 37 | ] 38 | 39 | elif self.scaleType == 'holtek': 40 | scales = [ 41 | {'min': 0, 'max': 21, 'female': [18, 23, 30, 35], 'male': [8, 14, 21, 25]}, 42 | {'min': 21, 'max': 26, 'female': [19, 24, 30, 35], 'male': [10, 15, 22, 26]}, 43 | {'min': 26, 'max': 31, 'female': [20, 25, 31, 36], 'male': [11, 16, 21, 27]}, 44 | {'min': 31, 'max': 36, 'female': [21, 26, 33, 36], 'male': [13, 17, 25, 28]}, 45 | {'min': 36, 'max': 41, 'female': [22, 27, 34, 37], 'male': [15, 20, 26, 29]}, 46 | {'min': 41, 'max': 46, 'female': [23, 28, 35, 38], 'male': [16, 22, 27, 30]}, 47 | {'min': 46, 'max': 51, 'female': [24, 30, 36, 38], 'male': [17, 23, 29, 31]}, 48 | {'min': 51, 'max': 56, 'female': [26, 31, 36, 39], 'male': [19, 25, 30, 33]}, 49 | {'min': 56, 'max': 100, 'female': [27, 32, 37, 40], 'male': [21, 26, 31, 34]}, 50 | ] 51 | 52 | for scale in scales: 53 | if self.age >= scale['min'] and self.age < scale['max']: 54 | return scale[self.sex] 55 | 56 | # Get muscle mass scale 57 | def getMuscleMassScale(self): 58 | if self.scaleType == 'xiaomi': 59 | scales = [ 60 | {'min': {'male': 170, 'female': 160}, 'female': [36.5, 42.6], 'male': [49.4, 59.5]}, 61 | {'min': {'male': 160, 'female': 150}, 'female': [32.9, 37.6], 'male': [44.0, 52.5]}, 62 | {'min': {'male': 0, 'female': 0}, 'female': [29.1, 34.8], 'male': [38.5, 46.6]}, 63 | ] 64 | elif self.scaleType == 'holtek': 65 | scales = [ 66 | {'min': {'male': 170, 'female': 170}, 'female': [36.5, 42.5], 'male': [49.5, 59.4]}, 67 | {'min': {'male': 160, 'female': 160}, 'female': [32.9, 37.5], 'male': [44.0, 52.4]}, 68 | {'min': {'male': 0, 'female': 0}, 'female': [29.1, 34.7], 'male': [38.5, 46.5]} 69 | ] 70 | 71 | for scale in scales: 72 | if self.height >= scale['min'][self.sex]: 73 | return scale[self.sex] 74 | 75 | 76 | 77 | # Get water percentage scale 78 | def getWaterPercentageScale(self): 79 | if self.scaleType == 'xiaomi': 80 | if self.sex == 'male': 81 | return [55.0, 65.1] 82 | elif self.sex == 'female': 83 | return [45.0, 60.1] 84 | elif self.scaleType == 'holtek': 85 | return [53, 67] 86 | 87 | 88 | # Get visceral fat scale 89 | def getVisceralFatScale(self): 90 | # Actually the same in mi fit/amazfit and holtek's sdk 91 | return [10.0, 15.0] 92 | 93 | 94 | # Get bone mass scale 95 | def getBoneMassScale(self): 96 | if self.scaleType == 'xiaomi': 97 | scales = [ 98 | {'male': {'min': 75.0, 'scale': [2.0, 4.2]}, 'female': {'min': 60.0, 'scale': [1.8, 3.9]}}, 99 | {'male': {'min': 60.0, 'scale': [1.9, 4.1]}, 'female': {'min': 45.0, 'scale': [1.5, 3.8]}}, 100 | {'male': {'min': 0.0, 'scale': [1.6, 3.9]}, 'female': {'min': 0.0, 'scale': [1.3, 3.6]}}, 101 | ] 102 | 103 | for scale in scales: 104 | if self.weight >= scale[self.sex]['min']: 105 | return scale[self.sex]['scale'] 106 | 107 | elif self.scaleType == 'holtek': 108 | scales = [ 109 | {'female': {'min': 60, 'optimal': 2.5}, 'male': {'min': 75, 'optimal': 3.2}}, 110 | {'female': {'min': 45, 'optimal': 2.2}, 'male': {'min': 69, 'optimal': 2.9}}, 111 | {'female': {'min': 0, 'optimal': 1.8}, 'male': {'min': 0, 'optimal': 2.5}} 112 | ] 113 | 114 | for scale in scales: 115 | if self.weight >= scale[self.sex]['min']: 116 | return [scale[self.sex]['optimal']-1, scale[self.sex]['optimal']+1] 117 | 118 | 119 | # Get BMR scale 120 | def getBMRScale(self): 121 | if self.scaleType == 'xiaomi': 122 | coefficients = { 123 | 'male': {30: 21.6, 50: 20.07, 100: 19.35}, 124 | 'female': {30: 21.24, 50: 19.53, 100: 18.63} 125 | } 126 | elif self.scaleType == 'holtek': 127 | coefficients = { 128 | 'female': {12: 34, 15: 29, 17: 24, 29: 22, 50: 20, 120: 19}, 129 | 'male': {12: 36, 15: 30, 17: 26, 29: 23, 50: 21, 120: 20} 130 | } 131 | 132 | for age, coefficient in coefficients[self.sex].items(): 133 | if self.age < age: 134 | return [self.weight * coefficient] 135 | 136 | 137 | # Get protein scale (hardcoded in mi fit) 138 | def getProteinPercentageScale(self): 139 | # Actually the same in mi fit and holtek's sdk 140 | return [16, 20] 141 | 142 | # Get ideal weight scale (BMI scale converted to weights) 143 | def getIdealWeightScale(self): 144 | scale = [] 145 | for bmiScale in self.getBMIScale(): 146 | scale.append((bmiScale*self.height)*self.height/10000) 147 | return scale 148 | 149 | # Get Body Score scale 150 | def getBodyScoreScale(self): 151 | # very bad, bad, normal, good, better 152 | return [50.0, 60.0, 80.0, 90.0] 153 | 154 | # Return body type scale 155 | def getBodyTypeScale(self): 156 | return ['obese', 'overweight', 'thick-set', 'lack-exerscise', 'balanced', 'balanced-muscular', 'skinny', 'balanced-skinny', 'skinny-muscular'] -------------------------------------------------------------------------------- /miscale/miscale_esp32.ino: -------------------------------------------------------------------------------- 1 | // WARNING use Arduino ESP32 library version 1.0.4, newer is unstable 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | // Scale MAC address, please use lowercase letters 14 | #define scale_mac_addr "00:00:00:00:00:00" 15 | 16 | // Network details 17 | const char* ssid = "ssid_name"; 18 | const char* password = "password"; 19 | 20 | // Synchronization status LED, for LOLIN32 D32 PRO is pin 5 21 | const int led_pin = 5; 22 | 23 | // Instantiating object of class Timestamp (time offset is possible in import_data.sh file) 24 | Timestamps ts(0); 25 | 26 | // Battery voltage measurement, for LOLIN32 D32 PRO is pin 35 27 | Battery18650Stats battery(35); 28 | 29 | // MQTT details 30 | const char* mqtt_server = "ip_address"; 31 | const int mqtt_port = 1883; 32 | const char* mqtt_userName = "admin"; 33 | const char* mqtt_userPass = "user_password"; 34 | const char* clientId = "esp32_scale"; 35 | const char* mqtt_attributes = "data"; 36 | 37 | String mqtt_clientId = String(clientId); 38 | String mqtt_topic_attributes = String(mqtt_attributes); 39 | String publish_data; 40 | 41 | WiFiClient espClient; 42 | PubSubClient mqtt_client(espClient); 43 | 44 | int16_t stoi(String input, uint16_t index1) { 45 | return (int16_t)(strtol(input.substring(index1, index1+2).c_str(), NULL, 16)); 46 | } 47 | int16_t stoi2(String input, uint16_t index1) { 48 | return (int16_t)(strtol((input.substring(index1+2, index1+4) + input.substring(index1, index1+2)).c_str(), NULL, 16)); 49 | } 50 | 51 | void goToDeepSleep() { 52 | // Deep sleep for 7 minutes 53 | Serial.println("* Waiting for next scan, going to sleep"); 54 | esp_sleep_enable_timer_wakeup(7 * 60 * 1000000); 55 | esp_deep_sleep_start(); 56 | } 57 | 58 | void StartESP32() { 59 | // LED indicate start ESP32, is on for 0.25 second 60 | pinMode(led_pin, OUTPUT); 61 | digitalWrite(led_pin, LOW); 62 | delay(250); 63 | digitalWrite(led_pin, HIGH); 64 | 65 | // Initializing serial port for debugging purposes, version info 66 | Serial.begin(115200); 67 | Serial.println(); 68 | Serial.println("================================================"); 69 | Serial.println("Export 2 Garmin Connect v3.0 (miscale_esp32.ino)"); 70 | Serial.println("================================================"); 71 | Serial.println(); 72 | } 73 | 74 | void errorLED_connect() { 75 | pinMode(led_pin, OUTPUT); 76 | digitalWrite(led_pin, LOW); 77 | delay(5000); 78 | Serial.println("failed"); 79 | goToDeepSleep(); 80 | } 81 | 82 | void connectWiFi() { 83 | int nFailCount = 0; 84 | Serial.print("* Connecting to WiFi: "); 85 | while (WiFi.status() != WL_CONNECTED) { 86 | WiFi.mode(WIFI_STA); 87 | WiFi.begin(ssid, password); 88 | WiFi.waitForConnectResult(); 89 | if (WiFi.status() == WL_CONNECTED) { 90 | Serial.println("connected"); 91 | Serial.print(" IP address: "); 92 | Serial.println(WiFi.localIP()); 93 | } 94 | else { 95 | Serial.print("."); 96 | delay(200); 97 | nFailCount++; 98 | if (nFailCount > 75) 99 | errorLED_connect(); 100 | } 101 | } 102 | } 103 | 104 | void connectMQTT() { 105 | int nFailCount = 0; 106 | connectWiFi(); 107 | Serial.print("* Connecting to MQTT: "); 108 | while (!mqtt_client.connected()) { 109 | mqtt_client.setServer(mqtt_server, mqtt_port); 110 | if (mqtt_client.connect(mqtt_clientId.c_str(),mqtt_userName,mqtt_userPass)) { 111 | Serial.println("connected"); 112 | } 113 | else { 114 | Serial.print("."); 115 | delay(200); 116 | nFailCount++; 117 | if (nFailCount > 75) 118 | errorLED_connect(); 119 | } 120 | } 121 | } 122 | 123 | class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks { 124 | void onResult(BLEAdvertisedDevice advertisedDevice) { 125 | Serial.print(" BLE device found with address: "); 126 | Serial.print(advertisedDevice.getAddress().toString().c_str()); 127 | if (advertisedDevice.getAddress().toString() == scale_mac_addr) { 128 | Serial.println(" <= target device"); 129 | BLEScan *pBLEScan = BLEDevice::getScan(); // found what we want, stop now 130 | pBLEScan->stop(); 131 | } 132 | else { 133 | Serial.println(", non-target device"); 134 | } 135 | } 136 | }; 137 | 138 | void errorLED_scan() { 139 | pinMode(led_pin, OUTPUT); 140 | digitalWrite(led_pin, LOW); 141 | delay(5000); 142 | Serial.println("* Reading BLE data incomplete, finished BLE scan"); 143 | goToDeepSleep(); 144 | } 145 | 146 | void ScanBLE() { 147 | Serial.println("* Starting BLE scan:"); 148 | BLEDevice::init(""); 149 | BLEScan *pBLEScan = BLEDevice::getScan(); //Create new scan. 150 | pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks()); 151 | pBLEScan->setActiveScan(false); //Active scan uses more power. 152 | pBLEScan->setInterval(0x50); 153 | pBLEScan->setWindow(0x30); 154 | 155 | // Scan for 10 seconds 156 | BLEScanResults foundDevices = pBLEScan->start(10); 157 | int count = foundDevices.getCount(); 158 | for (int i = 0; i < count; i++) { 159 | BLEAdvertisedDevice d = foundDevices.getDevice(i); 160 | if (d.getAddress().toString() != scale_mac_addr) 161 | continue; 162 | String hex; 163 | if (d.haveServiceData()) { 164 | std::string md = d.getServiceData(); 165 | uint8_t* mdp = (uint8_t*)d.getServiceData().data(); 166 | char *pHex = BLEUtils::buildHexData(nullptr, mdp, md.length()); 167 | hex = pHex; 168 | free(pHex); 169 | } 170 | float Weight = stoi2(hex, 22) * 0.005; 171 | float Impedance = stoi2(hex, 18); 172 | if (Impedance > 0) { 173 | int Unix_time = ts.getTimestampUNIX(stoi2(hex, 4), stoi(hex, 8), stoi(hex, 10), stoi(hex, 12), stoi(hex, 14), stoi(hex, 16)); 174 | 175 | // LED blinking for 0.75 second, indicate finish reading BLE data 176 | Serial.println("* Reading BLE data complete, finished BLE scan"); 177 | digitalWrite(led_pin, LOW); 178 | delay(250); 179 | digitalWrite(led_pin, HIGH); 180 | delay(250); 181 | digitalWrite(led_pin, LOW); 182 | delay(250); 183 | digitalWrite(led_pin, HIGH); 184 | 185 | // Prepare to send raw values 186 | publish_data += String(Unix_time); 187 | publish_data += String(";"); 188 | publish_data += String(Weight, 1); 189 | publish_data += String(";"); 190 | publish_data += String(Impedance, 0); 191 | publish_data += String(";"); 192 | publish_data += String(battery.getBatteryVolts(), 1); 193 | publish_data += String(";"); 194 | publish_data += String(battery.getBatteryChargeLevel()); 195 | 196 | // Send data to MQTT broker and let app figure out the rest 197 | connectMQTT(); 198 | mqtt_client.publish(mqtt_topic_attributes.c_str(), publish_data.c_str(), true); 199 | Serial.print("* Publishing MQTT data: "); 200 | Serial.println(publish_data.c_str()); 201 | } 202 | else { 203 | errorLED_scan(); 204 | } 205 | } 206 | } 207 | 208 | void setup() { 209 | StartESP32(); 210 | ScanBLE(); 211 | goToDeepSleep(); 212 | } 213 | 214 | void loop() { 215 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Export 2 to Garmin Connect 2 | 3 | ## 1. Introduction 4 | ### 1.1. Miscale module (once known as miscale2garmin): 5 | - Allows fully automatic synchronization of Mi Body Composition Scale 2 (tested on XMTZC05HM) or Xiaomi Body Composition Scale S400 (tested on MJTZC01YM) directly to Garmin Connect, with following parameters: 6 | - Date and Time (measurement, from device); 7 | - Weight (**_NOTE:_ lbs is automatically converted to kg**, applies to Mi Body Composition Scale 2); 8 | - BMI (Body Mass Index); 9 | - Body Fat; 10 | - Skeletal Muscle Mass; 11 | - Bone Mass; 12 | - Body Water; 13 | - Physique Rating; 14 | - Visceral Fat; 15 | - Metabolic Age; 16 | - Heart rate (Xiaomi Body Composition Scale S400 only, optional upload to blood pressure section). 17 | - Miscale_backup.csv file also contains other parameters (can be imported e.g. for analysis into Excel): 18 | - BMR (Basal Metabolic Rate); 19 | - LBM (Lean Body Mass); 20 | - Ideal Weight; 21 | - Fat Mass To Ideal; 22 | - Protein; 23 | - Data Status (to_import, failed, uploaded); 24 | - Unix Time (based on Date and Time); 25 | - Email User (used account for Garmin Connect); 26 | - Upload Date and Upload Time (to Garmin Connect); 27 | - Difference Time (between measuring and uploading); 28 | - Battery status in V and % (ESP32 - Mi Body Composition Scale 2 only); 29 | - Impedance; 30 | - Impedance Low (Xiaomi Body Composition Scale S400 only). 31 | - Supports multiple users with individual weights ranges, we can link multiple accounts with Garmin Connect. 32 | 33 | ### 1.2. Omron module: 34 | - Allows fully automatic synchronization of Omron blood pressure (tested on M4/HEM-7155T and M7/HEM-7322T Intelli IT) directly to Garmin Connect, with following parameters: 35 | - Date and Time (measurement, from device); 36 | - DIAstolic blood pressure; 37 | - SYStolic blood pressure; 38 | - Heart rate. 39 | - Omron_backup.csv file also contains other parameters (can be imported e.g. for analysis into Excel): 40 | - Category (**_NOTE:_ EU and US classification only**); 41 | - MOV (Movement detection); 42 | - IHB (Irregular Heart Beat); 43 | - Data Status (to_import, failed, uploaded); 44 | - Unix Time (based on Date and Time); 45 | - Email User (used account for Garmin Connect); 46 | - Upload Date and Upload Time (to Garmin Connect); 47 | - Difference Time (between measuring and uploading). 48 | - Supports 2 users from Omron device, we can connect 2 accounts with Garmin Connect. 49 | 50 | ### 1.3. User module: 51 | - Enables configuration of all parameters related to integration Miscale and Omron; 52 | - Provides export Oauth1 and Oauth2 tokens of your account from Garmin Connect (MFA/2FA support). 53 | 54 | ## 2. How does this work 55 | - Miscale and Omron modules can be activated individually or run together: 56 | - Devices can run together (Mi Body Composition Scale 2 and Omron); 57 | - Devices can run together but there must be a sequence, first measuring blood pressure and then weighing (Xiaomi Body Composition Scale S400 and Omron);
58 | This is because Xiaomi Body Composition Scale S400 requires continuous scanning, importing data from scale will allow you to go to Omron module. 59 | - Devices can run together in parallel but two USB Bluetooth adapters are required, one for scanning Xiaomi Body Composition Scale S400 and other for Omron;
60 | This is an using separate processes, view section [2.6.4.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/about_BLE.md#264-using-two-ble-adapters-in-parallel) 61 | - Synchronization diagram from Export 2 to Garmin Connect: 62 | 63 | ![alt text](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/workflow.png) 64 | 65 | ### 2.1. Miscale module | Mi Body Composition Scale 2 | BLE VERSION 66 | - After weighing, Mi Body Composition Scale 2 is active for 15 minutes on bluetooth transmission; 67 | - USB Bluetooth adapter or internal module scans BLE devices for 10 seconds to acquire data from scale; 68 | - Body weight and impedance data on server are appropriately processed by scripts; 69 | - Processed data are sent to Garmin Connect; 70 | - Raw and calculated data from scale is backed up on server in miscale_backup.csv file; 71 | - This part of project is **no longer being developed**, but it still works. 72 | 73 | **Select your platform and go to instructions:** 74 | - [Debian 13 | Raspberry Pi OS (based on Debian 13)](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_BLE.md); 75 | - [Windows 11](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/all_BLE_win.md). 76 | 77 | ### 2.2. Miscale module | Mi Body Composition Scale 2 | ESP32 VERSION 78 | - After weighing, Mi Body Composition Scale 2 is active for 15 minutes on bluetooth transmission; 79 | - ESP32 module operates in a deep sleep and wakes up every 7 minutes, scans BLE devices for 10 seconds to acquire data from scale, process can be started immediately via reset button; 80 | - ESP32 module sends acquired data via MQTT protocol to MQTT broker installed on server; 81 | - Body weight and impedance data on server are appropriately processed by scripts; 82 | - Processed data are sent to Garmin Connect; 83 | - Raw and calculated data from scale is backed up on server in miscale_backup.csv file; 84 | - This part of project is **no longer being developed**, but it still works. 85 | 86 | **Select your platform and go to instructions:** 87 | - [Debian 13 | Raspberry Pi OS (based on Debian 13)](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_ESP32.md); 88 | - [Windows 11](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_ESP32_win.md). 89 | 90 | ### 2.3. Miscale module | Xiaomi Body Composition Scale S400 | BLE VERSION 91 | - After weighing, Xiaomi Body Composition Scale S400 transmits weight data for a short while on bluetooth transmission; 92 | - A USB Bluetooth adapter scans BLE devices continuously to acquire data from scale; 93 | - Data from scale is decrypted and parsed into a readable form; 94 | - Body weight and impedance data on server are appropriately processed by scripts; 95 | - Processed data are sent to Garmin Connect; 96 | - Raw and calculated data from scale is backed up on server in miscale_backup.csv file. 97 | 98 | **Select your platform and go to instructions:** 99 | - [Debian 13 | Raspberry Pi OS (based on Debian 13)](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/S400_BLE.md); 100 | - [Windows 11](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/all_BLE_win.md). 101 | 102 | ### 2.4. Omron module | BLE VERSION 103 | - After measuring blood pressure, Omron allows you to download measurement data once; 104 | - USB bluetooth adapter or internal module scans BLE devices for 10 seconds to acquire data from blood pressure device (downloading data can take about 1 minute); 105 | - Pressure measurement data are appropriately processed by scripts on server; 106 | - Processed data are sent to Garmin Connect; 107 | - Raw and calculated data from device is backed up on server in omron_backup.csv file. 108 | 109 | **Select your platform and go to instructions:** 110 | - [Debian 13 | Raspberry Pi OS (based on Debian 13)](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Omron_BLE.md); 111 | - [Windows 11](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/all_BLE_win.md). 112 | 113 | ## 3. Mobile App 114 | I don't plan to create a mobile app, but I encourage you to take advantage of another projects (applies to Mi Body Composition Scale / Mi Scale / S400): 115 | - Android: https://github.com/lswiderski/mi-scale-exporter; 116 | - iOS | iPadOS: https://github.com/lswiderski/WebBodyComposition. 117 | 118 | ## 4. Synchronizing data between different ecosystems 119 | A very interesting project called SmartScaleConnect synchronizes scale data (applies to Mi Body Composition Scale 2 / S400) from Xiaomi cloud to Garmin: 120 | - Linux | MacOS | Windows: https://github.com/AlexxIT/SmartScaleConnect. 121 | 122 | ## If you like my work, you can buy me a coffee 123 | Buy Me A Coffee -------------------------------------------------------------------------------- /omron/sharedDriver.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger("omblepy") 3 | 4 | class sharedDeviceDriverCode(): 5 | #these need to be overwritten by device specific version 6 | deviceEndianess = None 7 | userStartAdressesList = None 8 | perUserRecordsCountList = None 9 | recordByteSize = None 10 | transmissionBlockSize = None 11 | settingsReadAddress = None 12 | settingsWriteAddress = None 13 | settingsUnreadRecordsBytes = None 14 | settingsTimeSyncBytes = None 15 | 16 | #abstract method, implemented by the device specific driver 17 | def deviceSpecific_ParseRecordFormat(self, singleRecordAsByteArray): 18 | raise NotImplementedError("Please Implement this method in the device specific file.") 19 | 20 | #abstract method, implemented by the device specific driver 21 | def deviceSpecific_syncWithSystemTime(self): 22 | raise NotImplementedError("Please Implement this method in the device specific file.") 23 | 24 | def _bytearrayBitsToInt(self, bytesArray, firstValidBitIdx, lastvalidBitIdx): 25 | bigInt = int.from_bytes(bytesArray, self.deviceEndianess) 26 | numValidBits = (lastvalidBitIdx-firstValidBitIdx) + 1 27 | shiftedBits = (bigInt>>(len(bytesArray) * 8 - (lastvalidBitIdx + 1))) 28 | bitmask = (2**(numValidBits)-1) 29 | return shiftedBits & bitmask 30 | 31 | def resetUnreadRecordsCounter(self): 32 | #special code for no new records is 0x8000 33 | unreadRecordsSettingsCopy = self.cachedSettingsBytes[slice(*self.settingsUnreadRecordsBytes)] 34 | resetUnreadRecordsBytes = (0x8000).to_bytes(2, byteorder=self.deviceEndianess) 35 | newUnreadRecordSettings = unreadRecordsSettingsCopy[:4] + resetUnreadRecordsBytes * 2 + unreadRecordsSettingsCopy[8:] 36 | self.cachedSettingsBytes[slice(*self.settingsUnreadRecordsBytes)] = newUnreadRecordSettings 37 | 38 | async def getRecords(self, btobj, useUnreadCounter, syncTime): 39 | await btobj.unlockWithUnlockKey() 40 | await btobj.startTransmission() 41 | 42 | #cache settings for time sync and for unread record counter 43 | 44 | if(syncTime or useUnreadCounter): 45 | #initialize cached settings bytes with zeros and use bytearray so that the values are mutable 46 | self.cachedSettingsBytes = bytearray(b'\0' * (self.settingsWriteAddress - self.settingsReadAddress)) 47 | for section in [self.settingsUnreadRecordsBytes, self.settingsTimeSyncBytes]: 48 | sectionNumBytes = section[1] - section[0] 49 | if(sectionNumBytes >= 54): 50 | raise ValueError("Section to big for a single read") 51 | self.cachedSettingsBytes[slice(*section)] = await btobj.readContinuousEepromData(self.settingsReadAddress+section[0], sectionNumBytes, sectionNumBytes) 52 | 53 | if(useUnreadCounter): 54 | allUsersReadCommandsList = await self._getReadCommands_OnlyNewRecords() 55 | else: 56 | allUsersReadCommandsList = await self._getReadCommands_AllRecords() 57 | 58 | #read records for all users 59 | logger.info("start reading data, this can take a while, use debug flag to see progress") 60 | allUserRecordsList = [] 61 | for userIdx, userReadCommandsList in enumerate(allUsersReadCommandsList): 62 | userConcatenatedRecordBytes = bytearray() 63 | for readCommand in userReadCommandsList: 64 | userConcatenatedRecordBytes += await btobj.readContinuousEepromData(readCommand["address"], readCommand["size"], self.transmissionBlockSize) 65 | #seperate the concatenated bytes into individual records 66 | perUserAnalyzedRecordsList = [] 67 | for recordStartOffset in range(0, len(userConcatenatedRecordBytes), self.recordByteSize): 68 | singleRecordBytes = userConcatenatedRecordBytes[recordStartOffset:recordStartOffset+self.recordByteSize] 69 | if singleRecordBytes != b'\xff' * self.recordByteSize: 70 | try: 71 | singleRecordDict = self.deviceSpecific_ParseRecordFormat(singleRecordBytes) 72 | perUserAnalyzedRecordsList.append(singleRecordDict) 73 | except: 74 | logger.warning(f"Error parsing record for user{userIdx+1} at offset {recordStartOffset} data {bytes(singleRecordBytes).hex()}, ignoring this record.") 75 | allUserRecordsList.append(perUserAnalyzedRecordsList) 76 | 77 | if(useUnreadCounter): 78 | self.resetUnreadRecordsCounter() 79 | 80 | #maybe this could be combined into a single write 81 | if(syncTime): 82 | self.deviceSpecific_syncWithSystemTime() 83 | bytesToWrite = self.cachedSettingsBytes[slice(*self.settingsTimeSyncBytes)] 84 | await btobj.writeContinuousEepromData(self.settingsWriteAddress + self.settingsTimeSyncBytes[0], bytesToWrite, btBlockSize = len(bytesToWrite)) 85 | if(useUnreadCounter): 86 | bytesToWrite = self.cachedSettingsBytes[slice(*self.settingsUnreadRecordsBytes)] 87 | await btobj.writeContinuousEepromData(self.settingsWriteAddress + self.settingsUnreadRecordsBytes[0], bytesToWrite, btBlockSize = len(bytesToWrite)) 88 | 89 | await btobj.endTransmission() 90 | return allUserRecordsList 91 | 92 | def calcRingBufferRecordReadLocations(self, userIdx, unreadRecords, lastWrittenSlot): 93 | userReadCommandsList = [] 94 | if(lastWrittenSlot < unreadRecords): #two reads neccesary, because ring buffer start reached 95 | #read start of ring buffer 96 | firstRead = dict() 97 | firstRead["address"] = self.userStartAdressesList[userIdx] 98 | firstRead["size"] = self.recordByteSize * lastWrittenSlot 99 | userReadCommandsList.append(firstRead) 100 | 101 | #read end of ring buffer 102 | secondRead = dict() 103 | secondRead["address"] = self.userStartAdressesList[userIdx] 104 | secondRead["address"] += (self.perUserRecordsCountList[userIdx] + lastWrittenSlot - unreadRecords) * self.recordByteSize 105 | secondRead["size"] = self.recordByteSize * (unreadRecords - lastWrittenSlot) 106 | userReadCommandsList.append(secondRead) 107 | else: 108 | #read start of ring buffer 109 | firstRead = dict() 110 | firstRead["address"] = self.userStartAdressesList[userIdx] 111 | firstRead["address"] += self.recordByteSize * (lastWrittenSlot - unreadRecords) 112 | firstRead["size"] = self.recordByteSize * unreadRecords 113 | userReadCommandsList.append(firstRead) 114 | return userReadCommandsList 115 | 116 | async def _getReadCommands_OnlyNewRecords(self): 117 | allUsersReadCommandsList = [] 118 | readRecordsInfoByteArray = self.cachedSettingsBytes[slice(*self.settingsUnreadRecordsBytes)] 119 | numUsers = len(self.userStartAdressesList) 120 | for userIdx in range(numUsers): 121 | #byte location depends on endianess, so use _bytearrayBitsToInt to account for this 122 | lastWrittenSlotForUser = self._bytearrayBitsToInt(readRecordsInfoByteArray[2*userIdx+0:2*userIdx+2], 8, 15) 123 | unreadRecordsForUser = self._bytearrayBitsToInt(readRecordsInfoByteArray[2*userIdx+4:2*userIdx+6], 8, 15) 124 | 125 | logger.info(f"Current ring buffer slot user{userIdx+1}: {lastWrittenSlotForUser}.") 126 | logger.info(f"Unread records user{userIdx+1}: {unreadRecordsForUser}.") 127 | readCmds = self.calcRingBufferRecordReadLocations(userIdx, unreadRecordsForUser, lastWrittenSlotForUser) 128 | allUsersReadCommandsList.append(readCmds) 129 | return allUsersReadCommandsList 130 | async def _getReadCommands_AllRecords(self): 131 | allUsersReadCommandsList = [] 132 | for userIdx, userStartAddress in enumerate(self.userStartAdressesList): 133 | readCommand = dict() 134 | readCommand["address"] = userStartAddress 135 | readCommand["size"] = self.perUserRecordsCountList[userIdx] * self.recordByteSize 136 | singleUserReadCommands = [readCommand] 137 | allUsersReadCommandsList.append(singleUserReadCommands) 138 | return allUsersReadCommandsList 139 | -------------------------------------------------------------------------------- /manuals/Miscale_BLE.md: -------------------------------------------------------------------------------- 1 | ## 2.1. Miscale_BLE VERSION 2 | - This module is based on following projects: 3 | - https://github.com/cyberjunky/python-garminconnect; 4 | - https://github.com/wiecosystem/Bluetooth; 5 | - https://github.com/lolouk44/xiaomi_mi_scale. 6 | 7 | ### 2.1.1. Getting MAC address of Mi Body Composition Scale 2 / disable weigh small object 8 | - Install Zepp Life App on your mobile device from Play Store; 9 | - Configure your scale with Zepp Life App on your mobile device (tested on Android 10-15); 10 | - Retrieve scale's MAC address from Zepp Life App (Profile > My devices > Mi Body Composition Scale 2); 11 | - Turn off weigh small object in Zepp Life App (Profile > My devices > Mi Body Composition Scale 2) for better measurement quality. 12 | 13 | ### 2.1.2. Setting correct date and time in Mi Body Composition Scale 2 14 | - Launch Zepp Life App, go to scale (Profile > My devices > Mi Body Composition Scale 2); 15 | - Start scale and select Clear data in App; 16 | - Take a new weight measurement with App, App should synchronize date and time (UTC); 17 | - You should also synchronize scale after replacing batteries. 18 | 19 | ### 2.1.3. Preparing operating system 20 | - Minimum hardware and software requirements are: 21 | - x86: 1vCPU, 1024MB RAM, 8GB disk space, network connection, Debian 13 operating system; 22 | - ARM: Minimum is Raspberry Pi Zero 2 W, 8GB disk space, Raspberry Pi operating system; 23 | - Check bluetooth adapter from support matrix [2.6.1.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/about_BLE.md#261-ble-adapters-support-matrix); 24 | - Update your system and then install following packages: 25 | ``` 26 | $ sudo apt update && sudo apt full-upgrade -y && sudo apt install -y wget python3 bc bluetooth python3-pip libglib2.0-dev procmail libssl-dev rfkill 27 | $ sudo pip3 install --upgrade bluepy garminconnect --break-system-packages 28 | ``` 29 | - Modify file `sudo nano /etc/systemd/system/bluetooth.target.wants/bluetooth.service`: 30 | ``` 31 | ExecStart=/usr/libexec/bluetooth/bluetoothd --experimental 32 | ``` 33 | - Download and extract to your home directory (e.g. "/home/robert/"), make a files executable: 34 | ``` 35 | $ wget https://github.com/RobertWojtowicz/export2garmin/archive/refs/heads/master.tar.gz -O - | tar -xz 36 | $ cd export2garmin-master && sudo chmod 755 import_data.sh && sudo chmod 555 /etc/bluetooth 37 | $ sudo setcap 'cap_net_raw,cap_net_admin+eip' /usr/local/lib/python3.13/dist-packages/bluepy/bluepy-helper 38 | ``` 39 | 40 | ### 2.1.4. Configuring scripts 41 | - First script is `user/import_tokens.py` is used to export Oauth1 and Oauth2 tokens of your account from Garmin Connect: 42 | - Script has support for login with or without MFA; 43 | - Once a year, tokens must be exported again, due to their expiration; 44 | - Repeat tokens export process for each user (if we have multiple users); 45 | - When you run `user/import_tokens.py`, you need to provide a login and password and possibly a code from MFA: 46 | ``` 47 | $ python3 /home/robert/export2garmin-master/user/import_tokens.py 48 | 49 | =============================================== 50 | Export 2 Garmin Connect v3.0 (import_tokens.py) 51 | =============================================== 52 | 53 | 28.04.2024-11:58:44 * Login e-mail: email@email.com 54 | 28.04.2024-11:58:50 * Enter password: 55 | 28.04.2024-11:58:57 * MFA/2FA one-time code: 000000 56 | 28.04.2024-11:59:17 * Oauth tokens saved correctly 57 | ``` 58 | - Configuration is stored in `user/export2garmin.cfg` file (make changes e.g. via `sudo nano`): 59 | - Complete data in "miscale_export_user*" parameter sex, height in cm, birthdate in dd-mm-yyyy, Login e-mail, max_weight in kg, min_weight in kg; 60 | - To enable scale in Miscale module, set "on" in "switch_miscale" parameter; 61 | - Complete data in "ble_miscale_mac" parameter, which is related to MAC address of scale, if you don't know MAC address read section [2.1.1.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_BLE.md#211-getting-mac-address-of-mi-body-composition-scale-2--disable-weigh-small-object); 62 | - Configuration file contains many **other options**, check descriptions and use for your configuration. 63 | - Second script `miscale/miscale_ble.py` has implemented debug mode, you can verify if everything is working properly, just execute it from console: 64 | ``` 65 | $ python3 /home/robert/export2garmin-master/miscale/miscale_ble.py 66 | 67 | ============================================= 68 | Export 2 Garmin Connect v3.6 (miscale_ble.py) 69 | ============================================= 70 | 71 | 18.11.2023-23:23:30 * Checking if a BLE adapter is detected 72 | 18.11.2023-23:23:30 * BLE adapter hci0(00:00:00:00:00:00) detected, check BLE adapter connection 73 | 18.11.2023-23:23:30 * Connection to BLE adapter hci0(00:00:00:00:00:00) works, starting BLE scan: 74 | BLE device found with address: 00:00:00:00:00:00 <= target device 75 | 18.11.2023-23:23:34 MISCALE|OMRON * Reading BLE data complete, finished BLE scan 76 | 1672412076;58.4;521 77 | ``` 78 | - Third script `import_data.sh` has implemented debug mode, you can verify if everything is working properly, just execute it from console: 79 | ``` 80 | $ /home/robert/export2garmin-master/import_data.sh 81 | 82 | ============================================= 83 | Export 2 Garmin Connect v3.6 (import_data.sh) 84 | ============================================= 85 | 86 | 18.07.2024-16:56:01 SYSTEM * Main process runs on PID: 000 87 | 18.07.2024-16:56:01 SYSTEM * Path to temp files: /dev/shm/ 88 | 18.07.2024-16:56:01 SYSTEM * Path to user files: /home/robert/export2garmin-master/user/ 89 | 18.07.2024-16:56:01 SYSTEM * BLE adapter is ON in export2garmin.cfg file, check if available 90 | 18.07.2024-16:56:01 SYSTEM * BLE adapter hci0(00:00:00:00:00:00) working, check if temp.log file exists 91 | 18.07.2024-16:56:01 SYSTEM * temp.log file exists, go to modules 92 | 18.07.2024-16:56:07 MISCALE|S400 * Module is ON in export2garmin.cfg file 93 | 18.07.2024-16:56:07 MISCALE|S400 * miscale_backup.csv file exists, checking for new data 94 | 18.07.2024-16:56:07 MISCALE|S400 * Importing data from a BLE adapter 95 | 18.07.2024-16:56:39 MISCALE|S400 * Saving import 1721076654 to miscale_backup.csv file 96 | 18.07.2024-16:56:40 MISCALE|S400 * Calculating data from import 1721314552, upload to Garmin Connect 97 | 18.07.2024-16:56:40 MISCALE|S400 * Data upload to Garmin Connect is complete 98 | 18.07.2024-16:56:40 MISCALE|S400 * Saving calculated data from import 1721314552 to miscale_backup.csv file 99 | 18.07.2024-16:56:40 OMRON * Module is OFF in export2garmin.cfg file 100 | ``` 101 | - If there is an error upload to Garmin Connect, data will be sent again on next execution, upload errors and other operations are saved in temp.log file: 102 | ``` 103 | $ cat /dev/shm/temp.log 104 | 105 | ================================================ 106 | Export 2 Garmin Connect v3.5 (miscale_export.py) 107 | ================================================ 108 | 109 | MISCALE * Import data: 1721076654;55.2;508 110 | MISCALE * Calculated data: 15.07.2024;22:50;55.2;18.7;10.8;46.7;2.6;61.2;7;4;19;1217;51.1;64.4;to_gain:6.8;23.4;508;email@email.com;15.07.2024;23:00 111 | MISCALE * Upload status: OK 112 | ``` 113 | - Finally, if everything works correctly add script import_data.sh as a service, make sure about path: 114 | ``` 115 | $ find / -name import_data.sh 116 | /home/robert/export2garmin-master/import_data.sh 117 | ``` 118 | - To run it at system startup in an infinite loop, create a file `sudo nano /etc/systemd/system/export2garmin.service` enter previously searched path to import_data.sh and include "User" name: 119 | ``` 120 | [Unit] 121 | Description=Export2Garmin service 122 | After=network.target 123 | 124 | [Service] 125 | Type=simple 126 | User=robert 127 | ExecStart=/home/robert/export2garmin-master/import_data.sh -l 128 | Restart=on-failure 129 | 130 | [Install] 131 | WantedBy=multi-user.target 132 | ``` 133 | - Activate Export2Garmin service and run it: 134 | ``` 135 | sudo systemctl enable export2garmin.service && sudo systemctl start export2garmin.service 136 | ``` 137 | - You can check if export2garmin service works `sudo systemctl status export2garmin.service` or temporarily stop it with command `sudo systemctl stop export2garmin.service`; 138 | - Checking running export2garmin service in a continuous loop: `sudo journalctl -u export2garmin.service -f` (process exit is Ctrl+C); 139 | - Troubleshooting BLE adapters [2.6.2.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/about_BLE.md#262-troubleshooting-ble-adapters); 140 | - Back to [README](https://github.com/RobertWojtowicz/export2garmin/blob/master/README.md). 141 | 142 | ## If you like my work, you can buy me a coffee 143 | Buy Me A Coffee -------------------------------------------------------------------------------- /manuals/Omron_BLE.md: -------------------------------------------------------------------------------- 1 | ## Omron_BLE VERSION 2 | - This module is based on following projects: 3 | - https://github.com/cyberjunky/python-garminconnect; 4 | - https://github.com/userx14/omblepy. 5 | 6 | ### 2.4.1. Preparing operating system 7 | - Minimum hardware and software requirements are: 8 | - x86: 1vCPU, 1024MB RAM, 8GB disk space, network connection, Debian 13 operating system; 9 | - ARM: Minimum is Raspberry Pi Zero 2 W, 8GB disk space, Raspberry Pi operating system; 10 | - Check bluetooth adapter from support matrix [2.6.1.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/about_BLE.md#261-ble-adapters-support-matrix); 11 | - Update your system and then install following packages: 12 | ``` 13 | $ sudo apt update && sudo apt full-upgrade -y && sudo apt install -y wget python3 bc bluetooth python3-pip libglib2.0-dev procmail libssl-dev rfkill 14 | $ sudo pip3 install --upgrade bluepy garminconnect bleak terminaltables --break-system-packages 15 | ``` 16 | - Modify file `sudo nano /etc/systemd/system/bluetooth.target.wants/bluetooth.service`: 17 | ``` 18 | ExecStart=/usr/libexec/bluetooth/bluetoothd --experimental 19 | ``` 20 | - Download and extract to your home directory (e.g. "/home/robert/"), make a files executable: 21 | ``` 22 | $ wget https://github.com/RobertWojtowicz/export2garmin/archive/refs/heads/master.tar.gz -O - | tar -xz 23 | $ cd export2garmin-master && sudo chmod 755 import_data.sh omron/omron_pairing.sh && sudo chmod 555 /etc/bluetooth 24 | $ sudo setcap 'cap_net_raw,cap_net_admin+eip' /usr/local/lib/python3.13/dist-packages/bluepy/bluepy-helper 25 | ``` 26 | 27 | ### 2.4.2. Disabling Auto Sync of Omron device 28 | - After measuring blood pressure, Omron allows you to download measurement data once; 29 | - If you have OMRON connect app, you must disable Auto Sync; 30 | - Because measurement data will be captured by app and integration will not be able to download data; 31 | - In app, go to three dots (More) > Profile > Connected devices and set Auto sync to Off. 32 | 33 | ### 2.4.3. Configuring scripts 34 | - First script is `user/import_tokens.py` is used to export Oauth1 and Oauth2 tokens of your account from Garmin Connect: 35 | - Script has support for login with or without MFA; 36 | - Once a year, tokens must be exported again, due to their expiration; 37 | - Repeat tokens export process for each user (if we have multiple users); 38 | - When you run `user/import_tokens.py`, you need to provide a login and password and possibly a code from MFA: 39 | ``` 40 | $ python3 /home/robert/export2garmin-master/user/import_tokens.py 41 | 42 | =============================================== 43 | Export 2 Garmin Connect v3.0 (import_tokens.py) 44 | =============================================== 45 | 46 | 28.04.2024-11:58:44 * Login e-mail: email@email.com 47 | 28.04.2024-11:58:50 * Enter password: 48 | 28.04.2024-11:58:57 * MFA/2FA one-time code: 000000 49 | 28.04.2024-11:59:17 * Oauth tokens saved correctly 50 | ``` 51 | - Configuration is stored in `user/export2garmin.cfg` file (make changes e.g. via `sudo nano`): 52 | - Complete data in "omron_export_user*" parameter by inserting your Login e-mail (same as `user/import_tokens.py`); 53 | - To enable Omron module, set "on" in "switch_omron" parameter; 54 | - Complete device model in "omron_omblepy_model" parameter, check out [Omron device support matrix](https://github.com/userx14/omblepy?tab=readme-ov-file#omron-device-support-matrix); 55 | - Put blood pressure monitor in pairing mode by pressing Bluetooth button for 3-5 seconds, You will see a flashing "P" on monitor; 56 | - Run second script`omron/omron_pairing.sh` find device starting with “BLEsmart_”, select ID and press Enter, wait for pairing: 57 | ``` 58 | $ /home/robert/export2garmin-master/omron/omron_pairing.sh 59 | 60 | =============================================== 61 | Export 2 Garmin Connect v3.0 (omron_pairing.sh) 62 | =============================================== 63 | 64 | 27.11.2024-12:56:16 SYSTEM * BLE adapter check if available 65 | 27.11.2024-12:56:38 SYSTEM * BLE adapter hci0(00:00:00:00:00:00) working, go to pairing 66 | 2024-11-27 12:56:38,688 - omblepy - INFO - Attempt to import module for device hem-7361t 67 | To improve your chance of a successful connection please do the following: 68 | -remove previous device pairings in your OS's bluetooth dialog 69 | -enable bluetooth on you omron device and use the specified mode (pairing or normal) 70 | -do not accept any pairing dialog until you selected your device in the following list 71 | 72 | Select your Omron device from the list below... 73 | +----+-------------------+-------------------------------+------+ 74 | | ID | MAC | NAME | RSSI | 75 | +----+-------------------+-------------------------------+------+ 76 | | 0 | 00:00:00:00:00:00 | BLESmart_0000025828FFB232E019 | -74 | 77 | +----+-------------------+-------------------------------+------+ 78 | Enter ID or just press Enter to rescan. 79 | ``` 80 | - Sometimes pairing process needs to be repeated because it freezes on timeout, just keep trying until it works; 81 | - Complete data in "omron_omblepy_mac" parameter which is related to MAC address of Omron device (read during pairing, MAC column); 82 | - Configuration file contains many **other options**, check descriptions and use for your configuration. 83 | - Third script is `import_data.sh` has implemented debug mode, you can verify if everything is working properly, just execute it from console: 84 | ``` 85 | $ /home/robert/export2garmin-master/import_data.sh 86 | 87 | ============================================= 88 | Export 2 Garmin Connect v3.6 (import_data.sh) 89 | ============================================= 90 | 91 | 18.07.2024-16:56:01 SYSTEM * Main process runs on PID: 000 92 | 18.07.2024-16:56:01 SYSTEM * Path to temp files: /dev/shm/ 93 | 18.07.2024-16:56:01 SYSTEM * Path to user files: /home/robert/export2garmin-master/user/ 94 | 18.07.2024-16:56:01 SYSTEM * BLE adapter is ON in export2garmin.cfg file, check if available 95 | 18.07.2024-16:56:01 SYSTEM * BLE adapter hci0(00:00:00:00:00:00) working, check if temp.log file exists 96 | 18.07.2024-16:56:01 SYSTEM * temp.log file exists, go to modules 97 | 18.07.2024-16:56:07 MISCALE|S400 * Module is OFF in export2garmin.cfg file 98 | 18.07.2024-16:56:07 OMRON * Module is ON in export2garmin.cfg file 99 | 18.07.2024-16:56:07 OMRON * omron_backup.csv file exists, checking for new data 100 | 18.07.2024-16:56:07 OMRON * Importing data from a BLE adapter 101 | 18.07.2024-16:56:39 OMRON * Prepare data for omron_backup.csv file 102 | 18.07.2024-16:56:40 OMRON * Calculating data from import 1721314552, upload to Garmin Connect 103 | 18.07.2024-16:56:40 OMRON * Data upload to Garmin Connect is complete 104 | 18.07.2024-16:56:40 OMRON * Saving calculated data from import 1721314552 to omron_backup.csv file 105 | ``` 106 | - If there is an error upload to Garmin Connect, data will be sent again on next execution, upload errors and other operations are saved in temp.log file: 107 | ``` 108 | $ cat /dev/shm/temp.log 109 | 110 | ============================================== 111 | Export 2 Garmin Connect v3.0 (omron_export.py) 112 | ============================================== 113 | 114 | OMRON * Import data: 1721231144;17.07.2024;17:45;82;118;65;0;0;email@email.com 115 | OMRON * Calculated data: Normal;0;0;email@email.com;17.07.2024;17:47 116 | OMRON * Upload status: OK 117 | ``` 118 | - Finally, if everything works correctly add script import_data.sh as a service, make sure about path: 119 | ``` 120 | $ find / -name import_data.sh 121 | /home/robert/export2garmin-master/import_data.sh 122 | ``` 123 | - To run it at system startup in an infinite loop, create a file `sudo nano /etc/systemd/system/export2garmin.service` enter previously searched path to import_data.sh and include "User" name: 124 | ``` 125 | [Unit] 126 | Description=Export2Garmin service 127 | After=network.target 128 | 129 | [Service] 130 | Type=simple 131 | User=robert 132 | ExecStart=/home/robert/export2garmin-master/import_data.sh -l 133 | Restart=on-failure 134 | 135 | [Install] 136 | WantedBy=multi-user.target 137 | ``` 138 | - Activate Export2Garmin service and run it: 139 | ``` 140 | $ sudo systemctl enable export2garmin.service && sudo systemctl start export2garmin.service 141 | ``` 142 | - You can check if export2garmin service works `sudo systemctl status export2garmin.service` or temporarily stop it with command `sudo systemctl stop export2garmin.service`; 143 | - Checking running export2garmin service in a continuous loop: `sudo journalctl -u export2garmin.service -f` (process exit is Ctrl+C); 144 | - Troubleshooting BLE adapters [2.6.2.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/about_BLE.md#262-troubleshooting-ble-adapters); 145 | - Back to [README](https://github.com/RobertWojtowicz/export2garmin/blob/master/README.md). 146 | 147 | ## If you like my work, you can buy me a coffee 148 | Buy Me A Coffee -------------------------------------------------------------------------------- /miscale/miscale_ble.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import subprocess 5 | import argparse 6 | import time 7 | from datetime import datetime as dt 8 | from bluepy import btle 9 | 10 | # Version info 11 | print(""" 12 | ============================================= 13 | Export 2 Garmin Connect v3.6 (miscale_ble.py) 14 | ============================================= 15 | """) 16 | 17 | # Importing bluetooth variables from a file 18 | path = os.path.dirname(os.path.dirname(__file__)) 19 | with open(path + '/user/export2garmin.cfg', 'r') as file: 20 | for line in file: 21 | line = line.strip() 22 | if line.startswith('ble_') or line.startswith('switch_'): 23 | name, value = line.split('=') 24 | globals()[name.strip()] = value.strip() 25 | 26 | # Arguments to pass in script 27 | parser = argparse.ArgumentParser() 28 | parser.add_argument("-a", default=ble_arg_hci) 29 | parser.add_argument("-bt", default=ble_arg_hci2mac) 30 | parser.add_argument("-mac", default=ble_arg_mac) 31 | args = parser.parse_args() 32 | ble_arg_hci = args.a 33 | ble_arg_hci2mac = args.bt 34 | ble_arg_mac = args.mac 35 | 36 | # Reading data from a scale using a BLE adapter 37 | class miScale(btle.DefaultDelegate): 38 | def __init__(self): 39 | btle.DefaultDelegate.__init__(self) 40 | self.address = ble_miscale_mac.lower() 41 | self.unique_dev_addresses = [] 42 | self.ble_adapter_time = int(ble_adapter_time) 43 | self.ble_adapter_repeat = int(ble_adapter_repeat) 44 | def handleDiscovery(self, dev, isNewDev, isNewData): 45 | if dev.addr not in self.unique_dev_addresses: 46 | self.unique_dev_addresses.append(dev.addr) 47 | print(f" BLE device found with address: {dev.addr}" + (" <= target device" if dev.addr == self.address else ", non-target device")) 48 | if dev.addr == self.address: 49 | if switch_miscale == 'off' and (switch_s400 == 'on' or switch_omron == 'on'): 50 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} S400|OMRON * BLE scan test completed successfully") 51 | exit() 52 | if switch_miscale == 'on' and (switch_s400 == 'off' or switch_omron == 'on'): 53 | for (adType, desc, value) in dev.getScanData(): 54 | if adType == 22: 55 | data = bytes.fromhex(value[4:]) 56 | ctrlByte1 = data[1] 57 | hasImpedance = ctrlByte1 & (1<<1) 58 | if hasImpedance: 59 | 60 | # lbs to kg unit conversion 61 | if value[4:6] == '03': 62 | lb_weight = int((value[28:30] + value[26:28]), 16) * 0.01 63 | weight = round(lb_weight / 2.2046, 1) 64 | else: 65 | weight = (((data[12] & 0xFF) << 8) | (data[11] & 0xFF)) * 0.005 66 | impedance = ((data[10] & 0xFF) << 8) | (data[9] & 0xFF) 67 | unix_time = int(dt.timestamp(dt.strptime(f"{int((data[3] << 8) | data[2])},{int(data[4])},{int(data[5])},{int(data[6])},{int(data[7])},{int(data[8])}","%Y,%m,%d,%H,%M,%S"))) 68 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} MISCALE|OMRON * Reading BLE data complete, finished BLE scan") 69 | print(f"{unix_time};{weight:.1f};{impedance:.0f}") 70 | else: 71 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} MISCALE|OMRON * Reading BLE data incomplete, finished BLE scan") 72 | exit() 73 | else: 74 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} MISCALE|S400|OMRON * Incorrect configuration in export2garmin.cfg file, finished BLE scan") 75 | exit() 76 | 77 | # Verifying correct working of BLE adapter, max 3 times 78 | def restart_bluetooth(self): 79 | subprocess.run(["rfkill", "unblock", "bluetooth"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 80 | time.sleep(1) 81 | subprocess.run(["modprobe", "btusb"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 82 | time.sleep(1) 83 | subprocess.run(["systemctl", "restart", "bluetooth.service"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 84 | time.sleep(1) 85 | def run(self): 86 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * Checking if a BLE adapter is detected") 87 | ble_error = 0 88 | ble_success = False 89 | while ble_error < 3: 90 | ble_error += 1 91 | hci_out = subprocess.check_output(["hcitool", "dev"], stderr=subprocess.DEVNULL).decode() 92 | if ble_arg_hci2mac == "on": 93 | if ble_arg_mac not in hci_out: 94 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * BLE adapter {ble_arg_mac} not detected, restarting bluetooth service") 95 | else: 96 | ble_arg_hci_read = [line.split()[0][-1] for line in hci_out.splitlines() if ble_arg_mac in line][0] 97 | ble_arg_mac_read = ble_arg_mac 98 | ble_success = True 99 | else: 100 | if f"hci{ble_arg_hci}" not in hci_out: 101 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * BLE adapter hci{ble_arg_hci} not detected, restarting bluetooth service") 102 | else: 103 | ble_arg_hci_read = ble_arg_hci 104 | ble_arg_mac_read = [line.split()[1] for line in hci_out.splitlines() if f"hci{ble_arg_hci}" in line][0] 105 | ble_success = True 106 | if ble_success is False: 107 | self.restart_bluetooth() 108 | else: 109 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * BLE adapter hci{ble_arg_hci_read}({ble_arg_mac_read}) detected, check BLE adapter connection") 110 | break 111 | if ble_error == 3 and not ble_success: 112 | if ble_arg_hci2mac == "on": 113 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * BLE adapter {ble_arg_mac} failed to be found, not detected by {ble_error} attempts") 114 | else: 115 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * BLE adapter hci{ble_arg_hci} failed to be found, not detected by {ble_error} attempts") 116 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * Finished BLE scan") 117 | return 118 | 119 | # Verifying correct working of BLE adapter connection, max 3 times 120 | con_error = 0 121 | con_success = False 122 | while con_error < 3 and ble_success: 123 | con_error += 1 124 | try: 125 | scanner = btle.Scanner(ble_arg_hci_read) 126 | scanner.withDelegate(self) 127 | scanner.start() 128 | scanner.stop() 129 | con_success = True 130 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * Connection to BLE adapter hci{ble_arg_hci_read}({ble_arg_mac_read}) works, starting BLE scan:") 131 | break 132 | except btle.BTLEManagementError: 133 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * Connection error, restarting BLE adapter hci{ble_arg_hci_read}({ble_arg_mac_read})") 134 | subprocess.run(["hciconfig", f"hci{ble_arg_hci_read}", "down"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 135 | time.sleep(1) 136 | subprocess.run(["hciconfig", f"hci{ble_arg_hci_read}", "up"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 137 | time.sleep(1) 138 | if con_error == 3 and not con_success: 139 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * Failed to connect to BLE adapter hci{ble_arg_hci_read}({ble_arg_mac_read}) by {con_error} attempts") 140 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * Finished BLE scan") 141 | return 142 | 143 | # Scanning for BLE devices in range, default 7 times 144 | dev_around = 0 145 | while ble_success and con_success: 146 | scanner.start() 147 | scanner.process(self.ble_adapter_time) 148 | scanner.stop() 149 | if ble_adapter_check == "on" and not self.unique_dev_addresses: 150 | dev_around += 1 151 | if dev_around < self.ble_adapter_repeat: 152 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * No devices around") 153 | elif dev_around == self.ble_adapter_repeat: 154 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * No devices around, restarting bluetooth service") 155 | self.restart_bluetooth() 156 | else: 157 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * No devices around, failed {dev_around} attempts") 158 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * Finished BLE scan") 159 | break 160 | else: 161 | print(f"{dt.now().strftime('%d.%m.%Y-%H:%M:%S')} * Finished BLE scan") 162 | break 163 | 164 | # Main program loop 165 | if __name__ == "__main__": 166 | scale = miScale() 167 | scale.run() -------------------------------------------------------------------------------- /miscale/Xiaomi_Scale_Body_Metrics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # Version info 4 | # =========================================================== 5 | # Export 2 Garmin Connect v3.0 (Xiaomi_Scale_Body_Metrics.py) 6 | # =========================================================== 7 | 8 | from math import floor 9 | import sys 10 | from body_scales import bodyScales 11 | 12 | class bodyMetrics: 13 | def __init__(self, weight, height, age, sex, impedance): 14 | self.weight = weight 15 | self.height = height 16 | self.age = age 17 | self.sex = sex 18 | self.impedance = impedance 19 | self.scales = bodyScales(age, height, sex, weight) 20 | 21 | # Check for potential out of boundaries 22 | if self.height > 220: 23 | print("Height is too high (limit: >220cm) or scale is sleeping") 24 | sys.stderr.write('Height is over 220cm\n') 25 | exit() 26 | elif weight < 10 or weight > 200: 27 | print("Weight is either too low or too high (limits: <10kg and >200kg)") 28 | sys.stderr.write('Weight is below 10kg or above 200kg\n') 29 | exit() 30 | elif age > 99: 31 | print("Age is too high (limit >99 years)") 32 | sys.stderr.write('Age is above 99 years\n') 33 | exit() 34 | elif impedance > 3000: 35 | print("Impedance is above 3000 Ohm") 36 | sys.stderr.write('Impedance is above 3000 Ohm\n') 37 | exit() 38 | 39 | # Set the value to a boundary if it overflows 40 | def checkValueOverflow(self, value, minimum, maximum): 41 | if value < minimum: 42 | return minimum 43 | elif value > maximum: 44 | return maximum 45 | else: 46 | return value 47 | 48 | # Get LBM coefficient (with impedance) 49 | def getLBMCoefficient(self): 50 | lbm = (self.height * 9.058 / 100) * (self.height / 100) 51 | lbm += self.weight * 0.32 + 12.226 52 | lbm -= self.impedance * 0.0068 53 | lbm -= self.age * 0.0542 54 | return lbm 55 | 56 | # Get BMR 57 | def getBMR(self): 58 | if self.sex == 'female': 59 | bmr = 864.6 + self.weight * 10.2036 60 | bmr -= self.height * 0.39336 61 | bmr -= self.age * 6.204 62 | else: 63 | bmr = 877.8 + self.weight * 14.916 64 | bmr -= self.height * 0.726 65 | bmr -= self.age * 8.976 66 | 67 | # Capping 68 | if self.sex == 'female' and bmr > 2996: 69 | bmr = 5000 70 | elif self.sex == 'male' and bmr > 2322: 71 | bmr = 5000 72 | return self.checkValueOverflow(bmr, 500, 10000) 73 | 74 | # Get fat percentage 75 | def getFatPercentage(self): 76 | # Set a constant to remove from LBM 77 | if self.sex == 'female' and self.age <= 49: 78 | const = 9.25 79 | elif self.sex == 'female' and self.age > 49: 80 | const = 7.25 81 | else: 82 | const = 0.8 83 | 84 | # Calculate body fat percentage 85 | LBM = self.getLBMCoefficient() 86 | 87 | if self.sex == 'male' and self.weight < 61: 88 | coefficient = 0.98 89 | elif self.sex == 'female' and self.weight > 60: 90 | coefficient = 0.96 91 | if self.height > 160: 92 | coefficient *= 1.03 93 | elif self.sex == 'female' and self.weight < 50: 94 | coefficient = 1.02 95 | if self.height > 160: 96 | coefficient *= 1.03 97 | else: 98 | coefficient = 1.0 99 | fatPercentage = (1.0 - (((LBM - const) * coefficient) / self.weight)) * 100 100 | 101 | # Capping body fat percentage 102 | if fatPercentage > 63: 103 | fatPercentage = 75 104 | return self.checkValueOverflow(fatPercentage, 5, 75) 105 | 106 | # Get water percentage 107 | def getWaterPercentage(self): 108 | waterPercentage = (100 - self.getFatPercentage()) * 0.7 109 | 110 | if (waterPercentage <= 50): 111 | coefficient = 1.02 112 | else: 113 | coefficient = 0.98 114 | 115 | # Capping water percentage 116 | if waterPercentage * coefficient >= 65: 117 | waterPercentage = 75 118 | return self.checkValueOverflow(waterPercentage * coefficient, 35, 75) 119 | 120 | # Get bone mass 121 | def getBoneMass(self): 122 | if self.sex == 'female': 123 | base = 0.245691014 124 | else: 125 | base = 0.18016894 126 | 127 | boneMass = (base - (self.getLBMCoefficient() * 0.05158)) * -1 128 | 129 | if boneMass > 2.2: 130 | boneMass += 0.1 131 | else: 132 | boneMass -= 0.1 133 | 134 | # Capping boneMass 135 | if self.sex == 'female' and boneMass > 5.1: 136 | boneMass = 8 137 | elif self.sex == 'male' and boneMass > 5.2: 138 | boneMass = 8 139 | return self.checkValueOverflow(boneMass, 0.5 , 8) 140 | 141 | # Get muscle mass 142 | def getMuscleMass(self): 143 | muscleMass = self.weight - ((self.getFatPercentage() * 0.01) * self.weight) - self.getBoneMass() 144 | 145 | # Capping muscle mass 146 | if self.sex == 'female' and muscleMass >= 84: 147 | muscleMass = 120 148 | elif self.sex == 'male' and muscleMass >= 93.5: 149 | muscleMass = 120 150 | 151 | return self.checkValueOverflow(muscleMass, 10 ,120) 152 | 153 | # Get Visceral Fat 154 | def getVisceralFat(self): 155 | if self.sex == 'female': 156 | if self.weight > (13 - (self.height * 0.5)) * -1: 157 | subsubcalc = ((self.height * 1.45) + (self.height * 0.1158) * self.height) - 120 158 | subcalc = self.weight * 500 / subsubcalc 159 | vfal = (subcalc - 6) + (self.age * 0.07) 160 | else: 161 | subcalc = 0.691 + (self.height * -0.0024) + (self.height * -0.0024) 162 | vfal = (((self.height * 0.027) - (subcalc * self.weight)) * -1) + (self.age * 0.07) - self.age 163 | else: 164 | if self.height < self.weight * 1.6: 165 | subcalc = ((self.height * 0.4) - (self.height * (self.height * 0.0826))) * -1 166 | vfal = ((self.weight * 305) / (subcalc + 48)) - 2.9 + (self.age * 0.15) 167 | else: 168 | subcalc = 0.765 + self.height * -0.0015 169 | vfal = (((self.height * 0.143) - (self.weight * subcalc)) * -1) + (self.age * 0.15) - 5.0 170 | 171 | return self.checkValueOverflow(vfal, 1 ,50) 172 | 173 | # Get BMI 174 | def getBMI(self): 175 | return self.checkValueOverflow(self.weight/((self.height/100)*(self.height/100)), 10, 90) 176 | 177 | # Get ideal weight (just doing a reverse BMI, should be something better) 178 | def getIdealWeight(self, orig=True): 179 | # Uses mi fit algorithm (or holtek's one) 180 | if orig and self.sex == 'female': 181 | return (self.height - 70) * 0.6 182 | elif orig and self.sex == 'male': 183 | return (self.height - 80) * 0.7 184 | else: 185 | return self.checkValueOverflow((22*self.height)*self.height/10000, 5.5, 198) 186 | 187 | # Get fat mass to ideal (guessing mi fit formula) 188 | def getFatMassToIdeal(self): 189 | mass = (self.weight * (self.getFatPercentage() / 100)) - (self.weight * (self.scales.getFatPercentageScale()[2] / 100)) 190 | if mass < 0: 191 | return f"to_gain:{mass*-1:.1f}" 192 | else: 193 | return f"to_lose:{mass:.1f}" 194 | 195 | # Get protetin percentage (warn: guessed formula) 196 | def getProteinPercentage(self, orig=True): 197 | # Use original algorithm from mi fit (or legacy guess one) 198 | if orig: 199 | proteinPercentage = (self.getMuscleMass() / self.weight) * 100 200 | proteinPercentage -= self.getWaterPercentage() 201 | else: 202 | proteinPercentage = 100 - (floor(self.getFatPercentage() * 100) / 100) 203 | proteinPercentage -= floor(self.getWaterPercentage() * 100) / 100 204 | proteinPercentage -= floor((self.getBoneMass()/self.weight*100) * 100) / 100 205 | 206 | return self.checkValueOverflow(proteinPercentage, 5, 32) 207 | 208 | # Get body type (out of nine possible) 209 | def getBodyType(self): 210 | if self.getFatPercentage() > self.scales.getFatPercentageScale()[2]: 211 | factor = 0 212 | elif self.getFatPercentage() < self.scales.getFatPercentageScale()[1]: 213 | factor = 2 214 | else: 215 | factor = 1 216 | 217 | if self.getMuscleMass() > self.scales.getMuscleMassScale()[1]: 218 | return 3 + (factor * 3) 219 | elif self.getMuscleMass() < self.scales.getMuscleMassScale()[0]: 220 | return 1 + (factor * 3) 221 | else: 222 | return 2 + (factor * 3) 223 | 224 | # Get Metabolic Age 225 | def getMetabolicAge(self): 226 | if self.sex == 'female': 227 | metabolicAge = (self.height * -1.1165) + (self.weight * 1.5784) + (self.age * 0.4615) + (self.impedance * 0.0415) + 83.2548 228 | else: 229 | metabolicAge = (self.height * -0.7471) + (self.weight * 0.9161) + (self.age * 0.4184) + (self.impedance * 0.0517) + 54.2267 230 | return self.checkValueOverflow(metabolicAge, 15, 80) -------------------------------------------------------------------------------- /manuals/Miscale_ESP32.md: -------------------------------------------------------------------------------- 1 | ## 2.2. Miscale_ESP32 VERSION 2 | - This module is based on following projects: 3 | - https://github.com/cyberjunky/python-garminconnect; 4 | - https://github.com/wiecosystem/Bluetooth; 5 | - https://github.com/lolouk44/xiaomi_mi_scale; 6 | - https://github.com/rando-calrissian/esp32_xiaomi_mi_2_hass. 7 | 8 | ### 2.2.1. Getting MAC address of Mi Body Composition Scale 2 / disable weigh small object 9 | - Install Zepp Life App on your mobile device from Play Store; 10 | - Configure your scale with Zepp Life App on your mobile device (tested on Android 10-15); 11 | - Retrieve scale's MAC address from Zepp Life App (Profile > My devices > Mi Body Composition Scale 2); 12 | - Turn off weigh small object in Zepp Life App (Profile > My devices > Mi Body Composition Scale 2) for better measurement quality. 13 | 14 | ### 2.2.2. Setting correct date and time in Mi Body Composition Scale 2 15 | - Launch Zepp Life App, go to scale (Profile > My devices > Mi Body Composition Scale 2); 16 | - Start scale and select Clear data in App; 17 | - Take a new weight measurement with App, App should synchronize date and time (UTC); 18 | - You should also synchronize scale after replacing batteries. 19 | 20 | ### 2.2.3. ESP32 configuration (bluetooth gateway to WiFi/MQTT) 21 | - Use Arduino IDE to compile and upload software to ESP32, following board and libraries required: 22 | - Arduino ESP32: https://github.com/espressif/arduino-esp32; 23 | - Battery 18650 Stats: https://github.com/danilopinotti/Battery18650Stats; 24 | - PubSubClient: https://github.com/knolleary/pubsubclient; 25 | - Timestamps: https://github.com/alve89/Timestamps. 26 | - How to install board and library in Arduino IDE?: 27 | - board (**_NOTE:_ use version 1.0.4, newer is unstable**): https://docs.espressif.com/projects/arduino-esp32/en/latest/installing.html; 28 | - libraries: https://www.arduino.cc/en/Guide/Libraries. 29 | - Preparing Arduino IDE to upload project to ESP32, go to Tools and select: 30 | - Board: > ESP32 Arduino > "WEMOS LOLIN32"; 31 | - Upload Speed: "921600"; 32 | - CPU Frequency: > "80MHz (WiFi / BT)" for better energy saving; 33 | - Flash Frequency: "80Mhz"; 34 | - Partition Scheme: > "No OTA (Large APP)"; 35 | - Port: > "COM" on which ESP32 board is detected. 36 | - Following information must be entered before compiling code (esp32.ino) in Arduino IDE: 37 | - MAC address of scale read from Zepp Life App ("scale_mac_addr"), if you don't know MAC address read section [2.2.1.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_ESP32.md#221-getting-mac-address-of-mi-body-composition-scale-2--disable-weigh-small-object); 38 | - Parameters of your WiFi network ("ssid", "password"); 39 | - Other settings ("led_pin", "Battery18650Stats"); 40 | - Connection parameters MQTT ("mqtt_server", "mqtt_port", "mqtt_userName", "mqtt_userPass"); 41 | - If you want to speed up download process, you can minimize ESP32 sleep time in "esp_sleep_enable_timer_wakeup" parameter (at expense of battery life). 42 | - Debug and comments: 43 | - Project is prepared to work with ESP32 board with charging module (red LED indicates charging). I based my version on Li-ion 18650 battery; 44 | - Program for ESP32 has implemented UART debug mode (baud rate must be set to 115200), you can verify if everything is working properly: 45 | ``` 46 | ================================================ 47 | Export 2 Garmin Connect v3.0 (miscale_esp32.ino) 48 | ================================================ 49 | 50 | * Starting BLE scan: 51 | BLE device found with address: 00:00:00:00:00:00 <= target device 52 | * Reading BLE data complete, finished BLE scan 53 | * Connecting to WiFi: connected 54 | IP address: 192.168.4.18 55 | * Connecting to MQTT: connected 56 | * Publishing MQTT data: 1672412076;58.4;521;3.5;5 57 | * Waiting for next scan, going to sleep 58 | ``` 59 | - After switching device on, blue LED will light up for a moment to indicate that module has started successfully; 60 | - If data are acquired correctly in next step, blue LED will flash for a moment 2 times; 61 | - If there is an error, e.g. data is incomplete, no connection to WiFi network or MQTT broker, blue LED will light up for 5 seconds; 62 | - Program implements voltage measurement and battery level, which are sent toger with scale data in topic MQTT; 63 | - Device has 2 buttons, first green is reset button (monostable), red is battery power switch (bistable). 64 | - Sample photo of finished module with ESP32 (Wemos LOLIN D32 Pro) and Li-ion 18650 battery (LG 3600mAh, LGDBM361865): 65 | 66 | ![alt text](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/Miscale_ESP32.jpg) 67 | 68 | ### 2.2.4. Preparing operating system 69 | - Minimum hardware and software requirements are: 70 | - x86: 1vCPU, 1024MB RAM, 8GB disk space, network connection, Debian 13 operating system; 71 | - ARM: Minimum Raspberry Pi Zero 2 W, 8GB disk space, Raspberry Pi operating system; 72 | - Update your system and then install following packages: 73 | ``` 74 | $ sudo apt update && sudo apt full-upgrade -y && sudo apt install -y wget python3 bc mosquitto mosquitto-clients python3-pip procmail libssl-dev 75 | $ sudo pip3 install --upgrade garminconnect --break-system-packages 76 | ``` 77 | - You need to set up a password for MQTT (password must be same as in ESP32): `sudo mosquitto_passwd -c /etc/mosquitto/passwd admin`; 78 | - Create a configuration file for Mosquitto: `sudo nano /etc/mosquitto/mosquitto.conf` and enter following parameters: 79 | ``` 80 | listener 1883 81 | allow_anonymous false 82 | password_file /etc/mosquitto/passwd 83 | ``` 84 | - Download and extract to your home directory (e.g. "/home/robert/"), make a files executable: 85 | ``` 86 | $ wget https://github.com/RobertWojtowicz/export2garmin/archive/refs/heads/master.tar.gz -O - | tar -xz 87 | $ cd export2garmin-master && sudo chmod 755 import_data.sh 88 | ``` 89 | 90 | ### 2.2.5. Configuring scripts 91 | - First script is `user/import_tokens.py` is used to export Oauth1 and Oauth2 tokens of your account from Garmin Connect: 92 | - Script has support for login with or without MFA; 93 | - Once a year, tokens must be exported again, due to their expiration; 94 | - Repeat tokens export process for each user (if we have multiple users); 95 | - When you run `user/import_tokens.py`, you need to provide a login and password and possibly a code from MFA: 96 | ``` 97 | $ python3 /home/robert/export2garmin-master/user/import_tokens.py 98 | 99 | =============================================== 100 | Export 2 Garmin Connect v3.0 (import_tokens.py) 101 | =============================================== 102 | 103 | 28.04.2024-11:58:44 * Login e-mail: email@email.com 104 | 28.04.2024-11:58:50 * Enter password: 105 | 28.04.2024-11:58:57 * MFA/2FA one-time code: 000000 106 | 28.04.2024-11:59:17 * Oauth tokens saved correctly 107 | ``` 108 | - Configuration is stored in `user/export2garmin.cfg` file (make changes e.g. via `sudo nano`): 109 | - Complete data in "miscale_export_user*" parameter: sex, height in cm, birthdate in dd-mm-yyyy, Login e-mail, max_weight in kg, min_weight in kg; 110 | - To enable Miscale module, set "on" in "switch_miscale" parameter; 111 | - Complete data in "miscale_mqtt_user", "miscale_mqtt_passwd" which are related to MQTT broker, "switch_mqtt" set to "on"; 112 | - Configuration file contains many **other options**, check descriptions and use for your configuration. 113 | - Second script `import_data.sh` has implemented debug mode, you can verify if everything is working properly, just execute it from console: 114 | ``` 115 | $ /home/robert/export2garmin-master/import_data.sh 116 | 117 | ============================================= 118 | Export 2 Garmin Connect v3.6 (import_data.sh) 119 | ============================================= 120 | 121 | 18.07.2024-16:56:01 SYSTEM * Main process runs on PID: 000 122 | 18.07.2024-16:56:01 SYSTEM * Path to temp files: /dev/shm/ 123 | 18.07.2024-16:56:01 SYSTEM * Path to user files: /home/robert/export2garmin-master/user/ 124 | 18.07.2024-16:56:01 SYSTEM * BLE adapter OFF or incorrect configuration in export2garmin.cfg file, check if temp.log file exists 125 | 18.07.2024-16:56:01 SYSTEM * temp.log file exists, go to modules 126 | 18.07.2024-16:56:07 MISCALE|S400 * Module is ON in export2garmin.cfg file 127 | 18.07.2024-16:56:07 MISCALE|S400 * miscale_backup.csv file exists, checking for new data 128 | 18.07.2024-16:56:07 MISCALE|S400 * Importing data from an MQTT broker 129 | 18.07.2024-16:56:39 MISCALE|S400 * Saving import 1721314552 to miscale_backup.csv file 130 | 18.07.2024-16:56:40 MISCALE|S400 * Calculating data from import 1721314552, upload to Garmin Connect 131 | 18.07.2024-16:56:40 MISCALE|S400 * Data upload to Garmin Connect is complete 132 | 18.07.2024-16:56:40 MISCALE|S400 * Saving calculated data from import 1721314552 to miscale_backup.csv file 133 | 18.07.2024-16:56:40 OMRON * Module is OFF in export2garmin.cfg file 134 | ``` 135 | - If there is an error upload to Garmin Connect, data will be sent again on next execution, upload errors and other operations are saved in temp.log file: 136 | ``` 137 | $ cat /dev/shm/temp.log 138 | 139 | ================================================ 140 | Export 2 Garmin Connect v3.5 (miscale_export.py) 141 | ================================================ 142 | 143 | MISCALE * Import data: 1721076654;55.2;508 144 | MISCALE * Calculated data: 15.07.2024;22:50;55.2;18.7;10.8;46.7;2.6;61.2;7;4;19;1217;51.1;64.4;to_gain:6.8;23.4;508;email@email.com;15.07.2024;23:00 145 | MISCALE * Upload status: OK 146 | ``` 147 | - Finally, if everything works correctly add script import_data.sh as a service, make sure about path: 148 | ``` 149 | $# find / -name import_data.sh 150 | /home/robert/export2garmin-master/import_data.sh 151 | ``` 152 | - To run it at system startup in an infinite loop, create a file `sudo nano /etc/systemd/system/export2garmin.service` enter previously searched path to import_data.sh and include "User" name: 153 | ``` 154 | [Unit] 155 | Description=Export2Garmin service 156 | After=network.target 157 | 158 | [Service] 159 | Type=simple 160 | User=robert 161 | ExecStart=/home/robert/export2garmin-master/import_data.sh -l 162 | Restart=on-failure 163 | 164 | [Install] 165 | WantedBy=multi-user.target 166 | ``` 167 | - Activate Export2Garmin service and run it: 168 | ``` 169 | sudo systemctl enable export2garmin.service && sudo systemctl start export2garmin.service 170 | ``` 171 | - You can check if export2garmin service works `sudo systemctl status export2garmin.service` or temporarily stop it with command `sudo systemctl stop export2garmin.service`; 172 | - Checking running export2garmin service in a continuous loop: `sudo journalctl -u export2garmin.service -f` (process exit is Ctrl+C); 173 | - Back to [README](https://github.com/RobertWojtowicz/export2garmin/blob/master/README.md). 174 | 175 | ## If you like my work, you can buy me a coffee 176 | Buy Me A Coffee -------------------------------------------------------------------------------- /manuals/S400_BLE.md: -------------------------------------------------------------------------------- 1 | ## 2.3. S400_BLE VERSION 2 | - This module is based on following projects: 3 | - https://github.com/cyberjunky/python-garminconnect; 4 | - https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor; 5 | - https://github.com/Bluetooth-Devices/xiaomi-ble. 6 | 7 | ### 2.3.1. Getting MAC address and BLE KEY of Xiaomi Body Composition Scale S400 8 | - Install Xiaomi Home App on your mobile device from Play Store; 9 | - Create an account and register your scale in app (tested on Android 15); 10 | - Take a measurement with scale using app (scale starts sending requested BLE advertisements); 11 | - A full measurement (weighing and heart rate) is required, otherwise scale will **not send data**; 12 | - You should also synchronize scale using app after **replacing batteries**; 13 | - Don't turn off heart rate measurement in app, otherwise scale will **not send data**; 14 | - In order for export2garmin to successfully download data from scale, **app must be closed**, bluetooth icon should blink; 15 | - For Windows download latest version and run: https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor/releases/latest/download/token_extractor.exe; 16 | - For Linux update your system and then install following packages: 17 | ``` 18 | $ sudo apt update && sudo apt full-upgrade -y && sudo apt install -y wget python3 bc bluetooth python3-pip libglib2.0-dev procmail libssl-dev libjpeg-dev rfkill unzip 19 | $ sudo pip3 install --upgrade bluepy garminconnect bleak xiaomi-ble requests pycryptodome charset-normalizer pillow colorama --break-system-packages 20 | ``` 21 | - Download and extract to your home directory and run token_extractor.py (e.g. "/home/robert/"): 22 | ``` 23 | $ wget https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor/releases/latest/download/token_extractor.zip 24 | $ unzip token_extractor.zip && rm token_extractor.zip 25 | $ cd token_extractor 26 | $ python3 /home/robert/token_extractor/token_extractor.py 27 | ``` 28 | - Complete data according to script, select p, use email address and password from your registered account in Xiaomi Home app, get BLE KEY and MAC; 29 | - Xiaomi Cloud Tokens Extractor asks for a captcha code, **requires a graphical interface and a web browser**: 30 | ``` 31 | Xiaomi Cloud 32 | ___ ____ _ _ ____ _ _ ____ ____ _ _ ___ ____ ____ ____ ___ ____ ____ 33 | | | | |_/ |___ |\ | [__ |___ \/ | |__/ |__| | | | | |__/ 34 | | |__| | \_ |___ | \| ___] |___ _/\_ | | \ | | |___ | |__| | \ 35 | by Piotr Machowski 36 | 37 | 38 | Please select a way to log in: 39 | p - using password 40 | q - using QR code 41 | p/q: p 42 | 43 | Username (email, phone number or user ID): 44 | email@email.com 45 | Password (not displayed for privacy reasons): 46 | 47 | 48 | Logging in... 49 | 50 | Captcha verification required. 51 | Image URL: http://127.0.0.1:31415 52 | Enter captcha as shown in the image (case-sensitive): 53 | 0000 54 | 55 | Two factor authentication required, please provide the code from the email. 56 | 57 | 2FA Code: 58 | 000000 59 | 60 | Logged in. 61 | 62 | Select server (one of: cn, de, us, ru, tw, sg, in, i2; Leave empty to check all available): 63 | de 64 | 65 | 66 | Devices found for server "de" @ home "000000000000": 67 | --------- 68 | NAME: Xiaomi Body Composition Scale S400 69 | ID: 000.0.0000000000000 70 | BLE KEY: 00000000000000000000000000000000 71 | MAC: 00:00:00:00:00:00 72 | TOKEN: 000000000000000000000000 73 | MODEL: yunmai.scales.ms104 74 | --------- 75 | ``` 76 | - BLE KEY is updated when a new profile in Xiaomi Home is connected to scale (they are replaced). 77 | 78 | ### 2.3.2. Preparing operating system 79 | - Minimum hardware and software requirements are: 80 | - x86: 1vCPU, 1024MB RAM, 8GB disk space, network connection, Debian 13 operating system; 81 | - ARM: Minimum is Raspberry Pi Zero 2 W, 8GB disk space, Raspberry Pi OS operating system; 82 | - Purchase a low-cost USB bluetooth adapter, **currently required** for synchronization to work, check from support matrix [2.6.1.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/about_BLE.md#261-ble-adapters-support-matrix); 83 | - Update your system and then install following packages (if you have not done so in section [2.3.1.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/S400_BLE.md#231-getting-mac-address-and-ble-key-of-xiaomi-body-composition-scale-s400)): 84 | ``` 85 | $ sudo apt update && sudo apt full-upgrade -y && sudo apt install -y wget python3 bc bluetooth python3-pip libglib2.0-dev procmail libssl-dev rfkill 86 | $ sudo pip3 install --upgrade bluepy garminconnect bleak xiaomi-ble --break-system-packages 87 | ``` 88 | - Modify file `sudo nano /etc/systemd/system/bluetooth.target.wants/bluetooth.service`: 89 | ``` 90 | ExecStart=/usr/libexec/bluetooth/bluetoothd --experimental 91 | ``` 92 | - Download and extract to your home directory (e.g. "/home/robert/"), make a files executable: 93 | ``` 94 | $ wget https://github.com/RobertWojtowicz/export2garmin/archive/refs/heads/master.tar.gz -O - | tar -xz 95 | $ cd export2garmin-master && sudo chmod 755 import_data.sh && sudo chmod 555 /etc/bluetooth 96 | $ sudo setcap 'cap_net_raw,cap_net_admin+eip' /usr/local/lib/python3.13/dist-packages/bluepy/bluepy-helper 97 | ``` 98 | 99 | ### 2.3.3. Configuring scripts 100 | - First script is `user/import_tokens.py` is used to export Oauth1 and Oauth2 tokens of your account from Garmin Connect: 101 | - Script has support for login with or without MFA; 102 | - Once a year, tokens must be exported again, due to their expiration; 103 | - Repeat tokens export process for each user (if we have multiple users); 104 | - When you run `user/import_tokens.py`, you need to provide a login and password and possibly a code from MFA: 105 | ``` 106 | $ python3 /home/robert/export2garmin-master/user/import_tokens.py 107 | 108 | =============================================== 109 | Export 2 Garmin Connect v3.0 (import_tokens.py) 110 | =============================================== 111 | 112 | 28.04.2024-11:58:44 * Login e-mail: email@email.com 113 | 28.04.2024-11:58:50 * Enter password: 114 | 28.04.2024-11:58:57 * MFA/2FA one-time code: 000000 115 | 28.04.2024-11:59:17 * Oauth tokens saved correctly 116 | ``` 117 | - Configuration is stored in `user/export2garmin.cfg` file (make changes e.g. via `sudo nano`): 118 | - Complete data in "miscale_export_user*" parameter sex, height in cm, birthdate in dd-mm-yyyy, Login e-mail, max_weight in kg, min_weight in kg; 119 | - To enable scale in Miscale module, set "on" in "switch_s400" parameter; 120 | - Complete data in "ble_miscale_mac" and "ble_miscale_key" parameter, which is related to MAC address and BLE KEY of scale, if you don't know MAC address or BLE KEY read section [2.3.1.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/S400_BLE.md#231-getting-mac-address-and-ble-key-of-xiaomi-body-composition-scale-s400); 121 | - Configuration file contains many **other options**, check descriptions and use for your configuration. 122 | - Second script `miscale/s400_ble.py` has implemented debug mode, you can verify if everything is working properly, just execute it from console: 123 | ``` 124 | $ python3 /home/robert/export2garmin-master/miscale/s400_ble.py 125 | 126 | ========================================== 127 | Export 2 Garmin Connect v3.6 (s400_ble.py) 128 | ========================================== 129 | 130 | 28.06.2025-12:56:31 * Starting scan with BLE adapter hci0(00:1A:7D:DA:71:13): 131 | BLE device found with address: 1C:EA:AC:5D:A7:B0 132 | 28.06.2025-12:57:01 S400 * Reading BLE data complete, finished BLE scan 133 | to_import;1751108221;58.1;509;468;79 134 | ``` 135 | - Third script `import_data.sh` has implemented debug mode, you can verify if everything is working properly, just execute it from console: 136 | ``` 137 | $ /home/robert/export2garmin-master/import_data.sh 138 | 139 | ============================================= 140 | Export 2 Garmin Connect v3.6 (import_data.sh) 141 | ============================================= 142 | 143 | 18.07.2024-16:56:01 SYSTEM * Main process runs on PID: 000 144 | 18.07.2024-16:56:01 SYSTEM * Path to temp files: /dev/shm/ 145 | 18.07.2024-16:56:01 SYSTEM * Path to user files: /home/robert/export2garmin-master/user/ 146 | 18.07.2024-16:56:01 SYSTEM * BLE adapter is ON in export2garmin.cfg file, check if available 147 | 18.07.2024-16:56:01 SYSTEM * BLE adapter hci0(00:00:00:00:00:00) working, check if temp.log file exists 148 | 18.07.2024-16:56:01 SYSTEM * temp.log file exists, go to modules 149 | 18.07.2024-16:56:07 MISCALE|S400 * Module is ON in export2garmin.cfg file 150 | 18.07.2024-16:56:07 MISCALE|S400 * miscale_backup.csv file exists, checking for new data 151 | 18.07.2024-16:56:07 MISCALE|S400 * Importing data from a BLE adapter 152 | 18.07.2024-16:56:39 MISCALE|S400 * Saving import 1721076654 to miscale_backup.csv file 153 | 18.07.2024-16:56:40 MISCALE|S400 * Calculating data from import 1721314552, upload to Garmin Connect 154 | 18.07.2024-16:56:40 MISCALE|S400 * Data upload to Garmin Connect is complete 155 | 18.07.2024-16:56:40 MISCALE|S400 * Saving calculated data from import 1721314552 to miscale_backup.csv file 156 | 18.07.2024-16:56:40 OMRON * Module is OFF in export2garmin.cfg file 157 | ``` 158 | - If there is an error upload to Garmin Connect, data will be sent again on next execution (required disable BLE scanning, set switch_bt=off parameter in ```user/export2garmin.cfg file```) 159 | - Upload errors and other operations are saved in temp.log file: 160 | ``` 161 | $ cat /dev/shm/temp.log 162 | 163 | ================================================ 164 | Export 2 Garmin Connect v3.5 (miscale_export.py) 165 | ================================================ 166 | 167 | MISCALE * Import data: 1721076654;55.2;508 168 | MISCALE * Calculated data: 15.07.2024;22:50;55.2;18.7;10.8;46.7;2.6;61.2;7;4;19;1217;51.1;64.4;to_gain:6.8;23.4;508;email@email.com;15.07.2024;23:00 169 | MISCALE * Upload status: OK 170 | ``` 171 | - Finally, if everything works correctly add script import_data.sh as a service, make sure about path: 172 | ``` 173 | $ find / -name import_data.sh 174 | /home/robert/export2garmin-master/import_data.sh 175 | ``` 176 | - To run it at system startup in an infinite loop, create a file `sudo nano /etc/systemd/system/export2garmin.service` enter previously searched path to import_data.sh and include "User" name: 177 | ``` 178 | [Unit] 179 | Description=Export2Garmin service 180 | After=network.target 181 | 182 | [Service] 183 | Type=simple 184 | User=robert 185 | ExecStart=/home/robert/export2garmin-master/import_data.sh -l 186 | Restart=on-failure 187 | 188 | [Install] 189 | WantedBy=multi-user.target 190 | ``` 191 | - Activate Export2Garmin service and run it: 192 | ``` 193 | sudo systemctl enable export2garmin.service && sudo systemctl start export2garmin.service 194 | ``` 195 | - You can check if export2garmin service works `sudo systemctl status export2garmin.service` or temporarily stop it with command `sudo systemctl stop export2garmin.service`; 196 | - Checking running export2garmin service in a continuous loop: `sudo journalctl -u export2garmin.service -f` (process exit is Ctrl+C); 197 | - Troubleshooting BLE adapters [2.6.2.](https://github.com/RobertWojtowicz/export2garmin/blob/master/manuals/about_BLE.md#262-troubleshooting-ble-adapters); 198 | - Back to [README](https://github.com/RobertWojtowicz/export2garmin/blob/master/README.md). 199 | 200 | ## If you like my work, you can buy me a coffee 201 | Buy Me A Coffee -------------------------------------------------------------------------------- /import_data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Version Info 4 | echo -e "\n=============================================" 5 | echo -e "Export 2 Garmin Connect v3.6 (import_data.sh)" 6 | echo -e "=============================================\n" 7 | 8 | # Blocking multiple instances of same script process 9 | path=$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd) 10 | source <(grep switch_ $path/user/export2garmin.cfg) 11 | timenow() { date +%d.%m.%Y-%H:%M:%S; } 12 | if lockfile -r 0 "$switch_temp_path/import.lock" 2>/dev/null ; then 13 | echo $BASHPID > "$switch_temp_path/import.pid" 14 | trap 'rm -f "$switch_temp_path/import.lock" "$switch_temp_path/import.pid"' EXIT 15 | import_pid=$(cat "$switch_temp_path/import.pid" 2>/dev/null) 16 | else import_pid=$(cat "$switch_temp_path/import.pid" 2>/dev/null) 17 | echo "$(timenow) SYSTEM * Import already in progress, skipping this run, PID is $import_pid" 18 | exit 1 19 | fi 20 | 21 | # Create a loop, "-l" parameter executes loop indefinitely 22 | loop_count=1 23 | found_count=0 24 | [[ $1 == "-l" ]] && loop_count=0 25 | i=0 26 | while [[ $loop_count -eq 0 ]] || [[ $i -lt $loop_count ]] ; do 27 | ((i++)) 28 | 29 | # Print location of variables for PID, temp and user files 30 | echo "$(timenow) SYSTEM * Main process runs on PID: $import_pid" 31 | echo "$(timenow) SYSTEM * Path to temp files: $switch_temp_path/" 32 | echo "$(timenow) SYSTEM * Path to user files: $path/user/" 33 | 34 | # Restart WiFi if it crashed 35 | if [[ $switch_wifi_watchdog == "on" ]] ; then 36 | if [[ $(nmcli -t -f WIFI g) == *enabled* ]] && [[ $(nmcli -t -f ACTIVE dev wifi) == yes* ]] ; then 37 | echo "$(timenow) SYSTEM * WiFi adapter working, go to verify BLE adapter" 38 | else 39 | echo "$(timenow) SYSTEM * WiFi adapter not working, restarting via nmcli" 40 | sudo nmcli radio wifi off 41 | sleep 1 42 | sudo nmcli radio wifi on 43 | fi 44 | fi 45 | 46 | # Verifying correct working of BLE, restart bluetooth service and device via miscale_ble.py 47 | if [[ $switch_bt == "on" ]] ; then 48 | if [[ $switch_miscale == "on" && $switch_mqtt == "off" ]] || [[ $switch_omron == "on" ]] || [[ $switch_s400 == "on" && $switch_s400_hci == "off" ]] ; then 49 | unset $(compgen -v | grep '^ble_') 50 | echo "$(timenow) SYSTEM * BLE adapter is ON in export2garmin.cfg file, check if available" 51 | ble_check=$(python3 -B $path/miscale/miscale_ble.py) 52 | if [[ $ble_check == *"failed"* ]] ; then 53 | echo "$(timenow) SYSTEM * BLE adapter not working, skip scanning check if temp.log file exists" 54 | else ble_status=ok 55 | [[ $ble_check =~ (h.{21}\)) ]] && hci_mac=${BASH_REMATCH[1]} 56 | echo "$(timenow) SYSTEM * BLE adapter $hci_mac working, check if temp.log file exists" 57 | fi 58 | else echo "$(timenow) SYSTEM * BLE adapter is OFF or incorrect configuration in export2garmin.cfg file, check if temp.log file exists" 59 | fi 60 | else echo "$(timenow) SYSTEM * BLE adapter is OFF or incorrect configuration in export2garmin.cfg file, check if temp.log file exists" 61 | fi 62 | 63 | # Create temp.log file if it exists cleanup after last startup 64 | if [[ $switch_miscale == "on" ]] || [[ $switch_omron == "on" ]] || [[ $switch_s400 == "on" ]] ; then 65 | temp_log=$switch_temp_path/temp.log 66 | if [[ ! -f $temp_log ]] ; then 67 | echo "$(timenow) SYSTEM * Creating temp.log file, go to modules" 68 | echo > $temp_log 69 | else echo "$(timenow) SYSTEM * temp.log file exists, go to modules" 70 | > $temp_log 71 | fi 72 | fi 73 | 74 | # Mi Body Composition Scale 2 & Xiaomi Body Composition Scale S400 75 | if [[ $switch_miscale == "on" ]] || [[ $switch_s400 == "on" ]] ; then 76 | miscale_backup=$path/user/miscale_backup.csv 77 | echo "$(timenow) MISCALE|S400 * Module is ON in export2garmin.cfg file" 78 | 79 | # Creating $miscale_backup file 80 | if [[ ! -f $miscale_backup ]] ; then 81 | miscale_header="Data Status;Unix Time;Date [dd.mm.yyyy];Time [hh:mm];Weight [kg];Change [kg];BMI;Body Fat [%];Skeletal Muscle Mass [kg];Bone Mass [kg];Body Water [%];Physique Rating;Visceral Fat;Metabolic Age [years];BMR [kCal];LBM [kg];Ideal Wieght [kg];Fat Mass To Ideal [type:mass kg];Protein [%];Impedance;Email User;Upload Date [dd.mm.yyyy];Upload Time [hh:mm];Difference Time [s]" 82 | [[ $switch_mqtt == "on" ]] && miscale_header="$miscale_header;Battery [V];Battery [%]" 83 | [[ $switch_s400 == "on" ]] && miscale_header="$miscale_header;Impedance Low;Heart Rate [bpm]" 84 | echo "$(timenow) MISCALE|S400 * Creating miscale_backup.csv file, checking for new data" 85 | echo $miscale_header > $miscale_backup 86 | else echo "$(timenow) MISCALE|S400 * miscale_backup.csv file exists, checking for new data" 87 | fi 88 | 89 | # Importing raw data from MQTT (Mi Body Composition Scale 2) 90 | if [[ $switch_mqtt == "on" && $switch_s400 == "off" ]] ; then 91 | source <(grep miscale_mqtt_ $path/user/export2garmin.cfg) 92 | echo "$(timenow) MISCALE|S400 * Importing data from an MQTT broker" 93 | miscale_read=$(mosquitto_sub -h localhost -t 'data' -u "$miscale_mqtt_user" -P "$miscale_mqtt_passwd" -C 1 -W 10) 94 | miscale_unixtime=${miscale_read%%;*} 95 | if [[ -z $miscale_unixtime ]] ; then 96 | echo "$(timenow) MISCALE|S400 * No MQTT data, check connection to MQTT broker or ESP32" 97 | fi 98 | 99 | # Importing raw data from BLE (Mi Body Composition Scale 2) 100 | elif [[ $ble_status == "ok" && $switch_s400 == "off" ]] ; then 101 | echo "$(timenow) MISCALE|S400 * Importing data from a BLE adapter" 102 | if [[ $ble_check == *"incomplete"* ]] ; then 103 | echo "$(timenow) MISCALE|S400 * Reading BLE data incomplete, repeat weighing" 104 | else miscale_read=$(echo $ble_check | awk '{sub(/.*BLE scan/, ""); print substr($1,1)}') 105 | miscale_unixtime=${miscale_read%%;*} 106 | fi 107 | 108 | # Importing raw data from BLE, within same process and hci (Xiaomi Body Composition Scale S400) 109 | elif [[ $ble_status == "ok" && $switch_s400 == "on" && $switch_s400_hci == "off" ]] ; then 110 | echo "$(timenow) MISCALE|S400 * Importing data from a BLE adapter" 111 | [[ $ble_check =~ hci([0-9]+) ]] && miscale_hci=${BASH_REMATCH[1]} 112 | miscale_s400_ble=$(python3 -B $path/miscale/s400_ble.py -a $miscale_hci) 113 | if [[ $miscale_s400_ble == *"failed"* ]] ; then 114 | echo "$(timenow) MISCALE|S400 * Reading BLE data failed, check configuration" 115 | else miscale_read=$(echo $miscale_s400_ble | awk '{sub(/.*BLE scan/, ""); print substr($1,1)}') 116 | 117 | # Save raw data to miscale_backup file (Xiaomi Body Composition Scale S400) 118 | miscale_unixtime=$(echo $miscale_read | awk -F';' '{print $2}') 119 | echo "$(timenow) MISCALE|S400 * Saving import $miscale_unixtime to miscale_backup.csv file" 120 | echo $miscale_read >> $miscale_backup 121 | fi 122 | 123 | # Importing raw data from BLE, within a separate process and hci (Xiaomi Body Composition Scale S400) 124 | elif [[ $switch_bt == "on" && $switch_s400 == "on" && $switch_s400_hci == "on" ]] ; then 125 | if lockfile -r 0 "$switch_temp_path/s400.lock" 2>/dev/null ; then 126 | s400_proc() { 127 | echo $BASHPID > "$switch_temp_path/s400.pid" 128 | trap 'rm -f "$switch_temp_path/s400.lock" "$switch_temp_path/s400.pid"' EXIT 129 | 130 | # Verifying correct working of BLE, restart bluetooth service and device via miscale_ble.py 131 | unset $(compgen -v | grep '^ble_') 132 | source <(grep ble_arg_ $path/user/export2garmin.cfg) 133 | source <(grep s400_arg_ $path/user/export2garmin.cfg) 134 | echo "$(timenow) S400 * A seperate BLE adapter is ON in export2garmin.cfg file, check if available" 135 | if [[ $ble_arg_hci == $s400_arg_hci && $ble_arg_hci2mac == "off" && $s400_arg_hci2mac == "off" ]] || [[ $ble_arg_mac == $s400_arg_mac && $ble_arg_hci2mac == "on" && $s400_arg_hci2mac == "on" ]]; then 136 | echo "$(timenow) S400 * The same BLE adapters, check arg_hci or arg_mac parameter in export2garmin.cfg" 137 | else ble_check=$(python3 -B $path/miscale/miscale_ble.py -a $s400_arg_hci -bt $s400_arg_hci2mac -mac $s400_arg_mac) 138 | if [[ $ble_check == *"failed"* ]] ; then 139 | echo "$(timenow) S400 * BLE adapter not working, skip scanning" 140 | else [[ $ble_check =~ (h.{21}\)) ]] && hci_mac=${BASH_REMATCH[1]} 141 | echo "$(timenow) S400 * BLE adapter $hci_mac working, importing data from a BLE adapter" 142 | [[ $ble_check =~ hci([0-9]+) ]] && miscale_hci=${BASH_REMATCH[1]} 143 | miscale_s400_ble=$(python3 -B $path/miscale/s400_ble.py -a $miscale_hci) 144 | if [[ $miscale_s400_ble == *failed* ]] ; then 145 | echo "$(timenow) S400 * Reading BLE data failed, check configuration" 146 | else miscale_read=$(echo $miscale_s400_ble | awk '{sub(/.*BLE scan/, ""); print substr($1,1)}') 147 | 148 | # Save raw data to miscale_backup file (Xiaomi Body Composition Scale S400) 149 | miscale_unixtime=$(echo $miscale_read | awk -F';' '{print $2}') 150 | echo "$(timenow) S400 * Saving import $miscale_unixtime to miscale_backup.csv file" 151 | echo $miscale_read >> $miscale_backup 152 | fi 153 | fi 154 | fi 155 | } 156 | s400_proc & s400_pid=$! 157 | else miscale_s400_pid=$(cat "$switch_temp_path/s400.pid" 2>/dev/null) 158 | echo "$(timenow) S400 * Import already in progress, process runs on PID: $miscale_s400_pid" 159 | fi 160 | fi 161 | 162 | # Check time synchronization between scale and OS (Mi Body Composition Scale 2) 163 | if [[ $switch_miscale == "on" && $switch_s400 == "off" ]] || [[ $switch_mqtt == "on" && $switch_s400 == "off" ]] ; then 164 | if [[ -n $miscale_unixtime ]] ; then 165 | source <(grep miscale_time_ $path/user/export2garmin.cfg) 166 | miscale_os_unixtime=$(date +%s) 167 | miscale_time_zone=$(printf '%.3s' "$(date +%z)") 168 | miscale_offset_unixtime=$(( $miscale_unixtime + $miscale_time_zone * 3600 + $miscale_time_offset )) 169 | miscale_time_shift=$(( $miscale_os_unixtime - $miscale_offset_unixtime )) 170 | miscale_absolute_shift=${miscale_time_shift#-} 171 | if (( $miscale_absolute_shift < $miscale_time_unsync )) ; then 172 | miscale_found_entry=false 173 | 174 | # Check for duplicates, similar raw data in $miscale_backup file (Mi Body Composition Scale 2) 175 | while IFS=";" read -r _ unix_time _ ; do 176 | if [[ $unix_time =~ ^[0-9]+$ ]] ; then 177 | miscale_time_dif=$(($miscale_offset_unixtime - $unix_time)) 178 | miscale_time_dif=${miscale_time_dif#-} 179 | if (( $miscale_time_dif < $miscale_time_check )) ; then 180 | miscale_found_entry=true 181 | break 182 | fi 183 | fi 184 | done < $miscale_backup 185 | 186 | # Save raw data to $miscale_backup file (Mi Body Composition Scale 2) 187 | if [[ $miscale_found_entry == "false" ]] ; then 188 | echo "$(timenow) MISCALE|S400 * Saving import $miscale_offset_unixtime to miscale_backup.csv file" 189 | miscale_offset_row=${miscale_read/${miscale_unixtime}/to_import;${miscale_offset_unixtime}} 190 | echo $miscale_offset_row >> $miscale_backup 191 | else echo "$(timenow) MISCALE|S400 * $miscale_time_dif s time difference, same or similar data already exists in miscale_backup.csv file" 192 | fi 193 | else echo "$(timenow) MISCALE|S400 * $miscale_time_shift s time difference, synchronize date and time scale" 194 | echo "$(timenow) MISCALE|S400 * Time offset is set to $miscale_offset s" 195 | fi 196 | fi 197 | fi 198 | 199 | # Calculating data and upload to Garmin Connect, print to temp.log file 200 | if [[ $(<"$miscale_backup") == *failed* ]] || [[ $(<"$miscale_backup") == *to_import* ]] ; then 201 | python3 -B $path/miscale/miscale_export.py > $temp_log 2>&1 202 | miscale_import=$(awk -F ": " '/MISCALE /*/ Import data:/{print substr($2,1,10)}' $temp_log) 203 | echo "$(timenow) MISCALE|S400 * Calculating data from import $miscale_import, upload to Garmin Connect" 204 | fi 205 | 206 | # Handling errors from temp.log file 207 | if [[ -z $miscale_import ]] ; then 208 | echo "$(timenow) MISCALE|S400 * There is no new data to upload to Garmin Connect" 209 | elif [[ $(<"$temp_log") == *"MISCALE * There"* ]] ; then 210 | echo "$(timenow) MISCALE|S400 * There is no user with given weight or undefined user email@email.com, check users section in export2garmin.cfg" 211 | echo "$(timenow) MISCALE|S400 * Deleting import $miscale_import from miscale_backup.csv file" 212 | sed -i "/$miscale_import/d" $miscale_backup 213 | elif [[ $(<"$temp_log") == *"Err"* ]] ; then 214 | echo "$(timenow) MISCALE|S400 * Upload to Garmin Connect has failed, check temp.log for error details" 215 | sed -i "s/to_import;$miscale_import/failed;$miscale_import/" $miscale_backup 216 | else echo "$(timenow) MISCALE|S400 * Data upload to Garmin Connect is complete" 217 | 218 | # Save calculated data to miscale_backup file 219 | echo "$(timenow) MISCALE|S400 * Saving calculated data from import $miscale_import to miscale_backup.csv file" 220 | miscale_import_data=$(awk -F ": " '/MISCALE /*/ Import data:/{print $2}' $temp_log) 221 | miscale_calc_data=$(awk -F ": " '/MISCALE /*/ Calculated data:/{print $2}' $temp_log) 222 | miscale_import_diff=$(echo $miscale_calc_data | cut -d ";" -f 1-3) 223 | miscale_check_line=$(wc -l < $miscale_backup) 224 | if [[ $switch_s400 == "on" ]] ; then 225 | miscale_os_unixtime=$(date +%s) 226 | miscale_time_shift=$(( $miscale_os_unixtime - $miscale_import )) 227 | fi 228 | sed -i "s/failed;$miscale_import_data/uploaded;$miscale_import;$miscale_calc_data;$miscale_time_shift/; s/to_import;$miscale_import_data/uploaded;$miscale_import;$miscale_calc_data;$miscale_time_shift/" $miscale_backup 229 | if [[ $miscale_check_line == "2" ]] ; then 230 | sed -i "s/$miscale_import;$miscale_import_diff/$miscale_import;$miscale_import_diff;0.0/" $miscale_backup 231 | else miscale_email_user=$(echo $miscale_calc_data | cut -d ";" -f 18) 232 | miscale_weight_last=$(grep $miscale_email_user $miscale_backup | sed -n 'x;$p' | cut -d ";" -f 5) 233 | miscale_weight_import=$(echo $miscale_calc_data | cut -d ";" -f 3) 234 | miscale_weight_diff=$(echo $miscale_weight_import - $miscale_weight_last | bc | sed "s/^-\./-0./; s/^\./0./") 235 | sed -i "s/$miscale_import;$miscale_import_diff/$miscale_import;$miscale_import_diff;$miscale_weight_diff/; s/;0;/;0.0;/; /^=\\+$/d" $miscale_backup 236 | fi 237 | fi 238 | unset $(compgen -v | grep '^miscale_') 239 | else echo "$(timenow) MISCALE|S400 * Module is OFF in export2garmin.cfg file" 240 | fi 241 | 242 | # Omron blood pressure 243 | if [[ $switch_omron == "on" ]] ; then 244 | omron_backup=$path/user/omron_backup.csv 245 | echo "$(timenow) OMRON * Module is ON in export2garmin.cfg file" 246 | 247 | # Creating omron_backup file 248 | if [[ ! -f $omron_backup ]] ; then 249 | echo "Data Status;Unix Time;Date [dd.mm.yyyy];Time [hh:mm];SYStolic [mmHg];DIAstolic [mmHg];Heart Rate [bpm];Category;MOV;IHB;Email User;Upload Date [dd.mm.yyyy];Upload Time [hh:mm];Difference Time [s]" > $omron_backup 250 | echo "$(timenow) OMRON * Creating omron_backup.csv file, checking for new data" 251 | else echo "$(timenow) OMRON * omron_backup.csv file exists, checking for new data" 252 | fi 253 | 254 | # Importing raw data from source (BLE) 255 | if [[ $ble_status == "ok" ]] ; then 256 | echo "$(timenow) OMRON * Importing data from a BLE adapter" 257 | coproc ble { bluetoothctl; } 258 | while true ; do 259 | source <(grep omron_omblepy_ $path/user/export2garmin.cfg) 260 | [[ $ble_check =~ (hci[0-9]+) ]] && omron_hci=${BASH_REMATCH[1]} 261 | omron_omblepy_check=$(timeout ${omron_omblepy_time}s python3 -B $path/omron/omblepy.py -a $omron_hci -p -d $omron_omblepy_model 2> /dev/null) 262 | if [[ $omron_omblepy_check == *"$omron_omblepy_mac"* ]] ; then 263 | 264 | # Adding an exception for selected models 265 | if [[ $omron_omblepy_model == "hem-6232t" ]] || [[ $omron_omblepy_model == "hem-7530t" ]] ; then 266 | omron_omblepy_flags="-n" 267 | else omron_omblepy_flags="-n -t" 268 | fi 269 | if [[ $omron_omblepy_debug == "on" ]] ; then 270 | python3 -B $path/omron/omblepy.py -a $omron_hci -d $omron_omblepy_model --loggerDebug -m $omron_omblepy_mac 271 | elif [[ $omron_omblepy_all == "on" ]] ; then 272 | python3 -B $path/omron/omblepy.py -a $omron_hci -d $omron_omblepy_model -m $omron_omblepy_mac >/dev/null 2>&1 273 | else python3 -B $path/omron/omblepy.py $omron_omblepy_flags -a $omron_hci -d $omron_omblepy_model -m $omron_omblepy_mac >/dev/null 2>&1 274 | fi 275 | else exec {ble[0]}>&- 276 | exec {ble[1]}>&- 277 | wait $ble_PID 278 | break 279 | fi 280 | done 281 | if [[ -f "$switch_temp_path/omron_user1.csv" ]] || [[ -f "$switch_temp_path/omron_user2.csv" ]] ; then 282 | source <(grep omron_export_user $path/user/export2garmin.cfg) 283 | echo "$(timenow) OMRON * Prepare data for omron_backup.csv file" 284 | awk -F ';' 'NR==FNR{a[$2];next}!($2 in a)' $omron_backup $switch_temp_path/omron_user1.csv > $switch_temp_path/omron_users.csv 285 | awk -F ';' 'NR==FNR{a[$2];next}!($2 in a)' $omron_backup $switch_temp_path/omron_user2.csv >> $switch_temp_path/omron_users.csv 286 | sed -i "s/ /;/g; s/user1/$omron_export_user1/; s/user2/$omron_export_user2/" $switch_temp_path/omron_users.csv 287 | grep -q "email@email.com" $switch_temp_path/omron_users.csv && echo "$(timenow) OMRON * Deleting records with undefined user email@email.com, check users section in export2garmin.cfg file" && sed -i "/email@email\.com/d" $switch_temp_path/omron_users.csv 288 | cat $switch_temp_path/omron_users.csv >> $omron_backup 289 | rm $switch_temp_path/omron_user*.csv 290 | fi 291 | fi 292 | 293 | # Upload to Garmin Connect, print to temp.log file 294 | if [[ $(<"$omron_backup") == *"failed"* ]] || [[ $(<"$omron_backup") == *"to_import"* ]] ; then 295 | if [[ $switch_miscale == "on" ]] ; then 296 | python3 -B $path/omron/omron_export.py >> $temp_log 2>&1 297 | else python3 -B $path/omron/omron_export.py > $temp_log 2>&1 298 | fi 299 | omron_import=$(awk -F ": " '/OMRON /*/ Import data:/{print substr($2,1,10)}' $temp_log) 300 | echo "$(timenow) OMRON * Calculating data from import $omron_import, upload to Garmin Connect" 301 | fi 302 | 303 | # Handling errors, save data to omron_backup file 304 | if [[ -z $omron_import ]] ; then 305 | echo "$(timenow) OMRON * There is no new data to upload to Garmin Connect" 306 | else omron_import_data=$(awk -F ": " '/OMRON /*/ Import data:/{print $2}' $temp_log) 307 | omron_cut_data=$(echo $omron_import_data | cut -d ";" -f 1-6) 308 | omron_calc_data=$(awk -F ": " '/OMRON /*/ Calculated data:/{print $2}' $temp_log) 309 | omron_os_unixtime=$(date +%s) 310 | omron_time_shift=$(( $omron_os_unixtime - $omron_import )) 311 | if [[ $(<"$temp_log") == *"Err"* ]] ; then 312 | if [[ $(<"$temp_log") == *"MISCALE * Upload"* ]] ; then 313 | echo "$(timenow) OMRON * Upload to Garmin Connect has failed, check temp.log file for error details" 314 | sed -i "s/to_import;$omron_import/failed;$omron_import/" $omron_backup 315 | elif [[ $(<"$temp_log") == *"OMRON * Upload"* ]] ; then 316 | echo "$(timenow) OMRON * Data upload to Garmin Connect is complete" 317 | echo "$(timenow) OMRON * Saving calculated data from import $omron_import to omron_backup.csv file" 318 | sed -i "s/failed;$omron_import_data/uploaded;omron_cut_data;$omron_calc_data;$omron_time_shift/; s/to_import;$omron_import_data/uploaded;$omron_cut_data;$omron_calc_data;$omron_time_shift/" $omron_backup 319 | else echo "$(timenow) OMRON * Upload to Garmin Connect has failed, check temp.log file for error details" 320 | sed -i "s/to_import;$omron_import/failed;$omron_import/" $omron_backup 321 | fi 322 | else echo "$(timenow) OMRON * Data upload to Garmin Connect is complete" 323 | echo "$(timenow) OMRON * Saving calculated data from import $omron_import to omron_backup.csv file" 324 | sed -i "s/failed;$omron_import_data/uploaded;$omron_cut_data;$omron_calc_data;$omron_time_shift/; s/to_import;$omron_import_data/uploaded;$omron_cut_data;$omron_calc_data;$omron_time_shift/" $omron_backup 325 | fi 326 | fi 327 | unset $(compgen -v | grep '^omron_') 328 | else echo "$(timenow) OMRON * Module is OFF in export2garmin.cfg file" 329 | fi 330 | if [[ $loop_count -eq 1 ]] ; then 331 | kill $s400_pid 2>/dev/null 332 | rm -f "$switch_temp_path/s400.lock $switch_temp_path/import.lock" 333 | break 334 | fi 335 | done -------------------------------------------------------------------------------- /omron/omblepy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # Version info 4 | # ================================================ 5 | # Export 2 Garmin Connect v3.0 (omblepy.py) 6 | # ================================================ 7 | 8 | import asyncio #avoid wait on bluetooth stack stalling the application 9 | import terminaltables #for pretty selection table for ble devices 10 | import bleak #bluetooth low energy package for python 11 | import re #regex to match bt mac address 12 | import argparse #to process command line arguments 13 | import datetime 14 | import time 15 | import sys 16 | import pathlib 17 | import logging 18 | import csv 19 | import os 20 | 21 | #global constants 22 | parentService_UUID = "ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b" 23 | 24 | #global variables 25 | bleClient = None 26 | examplePairingKey = bytearray.fromhex("deadbeaf12341234deadbeaf12341234") #arbitrary choise 27 | deviceSpecific = None #imported module for each device 28 | logger = logging.getLogger("omblepy") 29 | 30 | # Code change for Export2Garmin 31 | path = os.path.dirname(os.path.dirname(__file__)) 32 | with open(path + '/user/export2garmin.cfg', 'r') as file: 33 | for line in file: 34 | line = line.strip() 35 | if line.startswith('switch_temp_path'): 36 | name, value = line.split('=') 37 | globals()[name.strip()] = value.strip() 38 | 39 | def convertByteArrayToHexString(array): 40 | return (bytes(array).hex()) 41 | 42 | class bluetoothTxRxHandler: 43 | #BTLE Characteristic IDs 44 | deviceRxChannelUUIDs = [ 45 | "49123040-aee8-11e1-a74d-0002a5d5c51b", 46 | "4d0bf320-aee8-11e1-a0d9-0002a5d5c51b", 47 | "5128ce60-aee8-11e1-b84b-0002a5d5c51b", 48 | "560f1420-aee8-11e1-8184-0002a5d5c51b" 49 | ] 50 | deviceTxChannelUUIDs = [ 51 | "db5b55e0-aee7-11e1-965e-0002a5d5c51b", 52 | "e0b8a060-aee7-11e1-92f4-0002a5d5c51b", 53 | "0ae12b00-aee8-11e1-a192-0002a5d5c51b", 54 | "10e1ba60-aee8-11e1-89e5-0002a5d5c51b" 55 | ] 56 | deviceDataRxChannelIntHandles = [0x360, 0x370, 0x380, 0x390] 57 | deviceUnlock_UUID = "b305b680-aee7-11e1-a730-0002a5d5c51b" 58 | 59 | def __init__(self, pairing = False): 60 | self.currentRxNotifyStateFlag = False 61 | self.rxPacketType = None 62 | self.rxEepromAddress = None 63 | self.rxDataBytes = None 64 | self.rxFinishedFlag = False 65 | self.rxRawChannelBuffer = [None] * 4 #a buffer for each channel 66 | 67 | async def _enableRxChannelNotifyAndCallback(self): 68 | if(self.currentRxNotifyStateFlag != True): 69 | for rxChannelUUID in self.deviceRxChannelUUIDs: 70 | await bleClient.start_notify(rxChannelUUID, self._callbackForRxChannels) 71 | self.currentRxNotifyStateFlag = True 72 | 73 | async def _disableRxChannelNotifyAndCallback(self): 74 | if(self.currentRxNotifyStateFlag != False): 75 | for rxChannelUUID in self.deviceRxChannelUUIDs: 76 | await bleClient.stop_notify(rxChannelUUID) 77 | self.currentRxNotifyStateFlag = False 78 | 79 | def _callbackForRxChannels(self, BleakGATTChar, rxBytes): 80 | if type(BleakGATTChar) is int: 81 | rxChannelId = self.deviceDataRxChannelIntHandles.index(BleakGATTChar) 82 | else: 83 | rxChannelId = self.deviceDataRxChannelIntHandles.index(BleakGATTChar.handle) 84 | self.rxRawChannelBuffer[rxChannelId] = rxBytes 85 | 86 | logger.debug(f"rx ch{rxChannelId} < {convertByteArrayToHexString(rxBytes)}") 87 | if self.rxRawChannelBuffer[0]: #if there is data present in the first rx buffer 88 | packetSize = self.rxRawChannelBuffer[0][0] 89 | requiredChannels = range((packetSize + 15) // 16) 90 | #are all required channels already recieved 91 | for channelIdx in requiredChannels: 92 | if self.rxRawChannelBuffer[channelIdx] is None: #if one of the required channels is empty wait for more packets to arrive 93 | return 94 | 95 | #check crc 96 | combinedRawRx = bytearray() 97 | for channelIdx in requiredChannels: 98 | combinedRawRx += self.rxRawChannelBuffer[channelIdx] 99 | combinedRawRx = combinedRawRx[:packetSize] #cut extra bytes from the end 100 | xorCrc = 0 101 | for byte in combinedRawRx: 102 | xorCrc ^= byte 103 | if(xorCrc): 104 | raise ValueError(f"data corruption in rx\ncrc: {xorCrc}\ncombniedBuffer: {convertByteArrayToHexString(combinedRawRx)}") 105 | return 106 | #extract information 107 | self.rxPacketType = combinedRawRx[1:3] 108 | self.rxEepromAddress = combinedRawRx[3:5] 109 | expectedNumDataBytes = combinedRawRx[5] 110 | if(expectedNumDataBytes > (len(combinedRawRx) - 8)): 111 | self.rxDataBytes = bytes(b'\xff') * expectedNumDataBytes 112 | else: 113 | if(self.rxPacketType) == bytearray.fromhex("8f00"): #need special case for end of transmission packet, otherwise transmission error code is not accessible 114 | self.rxDataBytes = combinedRawRx[6:7] 115 | else: 116 | self.rxDataBytes = combinedRawRx[6: 6 + expectedNumDataBytes] 117 | self.rxRawChannelBuffer = [None] * 4 #clear channel buffers 118 | self.rxFinishedFlag = True 119 | return 120 | return 121 | 122 | async def _waitForRxOrRetry(self, command, timeoutS = 1.0): 123 | self.rxFinishedFlag = False 124 | retries = 0 125 | while True: 126 | commandCopy = command 127 | requiredTxChannels = range((len(command) + 15) // 16) 128 | for channelIdx in requiredTxChannels: 129 | logger.debug(f"tx ch{channelIdx} > {convertByteArrayToHexString(commandCopy[:16])}") 130 | await bleClient.write_gatt_char(self.deviceTxChannelUUIDs[channelIdx], commandCopy[:16]) 131 | commandCopy = commandCopy[16:] 132 | 133 | currentTimeout = timeoutS 134 | while(self.rxFinishedFlag == False): 135 | await asyncio.sleep(0.1) 136 | currentTimeout -= 0.1 137 | if(currentTimeout < 0): 138 | break 139 | if(currentTimeout >= 0): 140 | break 141 | retries += 1 142 | logger.warning(f"Transmission failed, count of retries: {retries} / 5") 143 | if(retries >= 5): 144 | ValueError("Same transmission failed 5 times, abort") 145 | return 146 | 147 | async def startTransmission(self): 148 | await self._enableRxChannelNotifyAndCallback() 149 | startDataReadout = bytearray.fromhex("0800000000100018") 150 | await self._waitForRxOrRetry(startDataReadout) 151 | if(self.rxPacketType != bytearray.fromhex("8000")): 152 | raise ValueError("invalid response to data readout start") 153 | 154 | async def endTransmission(self): 155 | stopDataReadout = bytearray.fromhex("080f000000000007") 156 | await self._waitForRxOrRetry(stopDataReadout) 157 | if(self.rxPacketType != bytearray.fromhex("8f00")): 158 | raise ValueError("invlid response to data readout end") 159 | return 160 | if(self.rxDataBytes[0]): 161 | raise ValueError(f"Device reported error status code {self.rxDataBytes[0]} while sending endTransmission command.") 162 | return 163 | await self._disableRxChannelNotifyAndCallback() 164 | 165 | async def _writeBlockEeprom(self, address, dataByteArray): 166 | dataWriteCommand = bytearray() 167 | dataWriteCommand += (len(dataByteArray) + 8).to_bytes(1, 'big') #total packet size with 6byte header and 2byte crc 168 | dataWriteCommand += bytearray.fromhex("01c0") 169 | dataWriteCommand += address.to_bytes(2, 'big') 170 | dataWriteCommand += len(dataByteArray).to_bytes(1, 'big') 171 | dataWriteCommand += dataByteArray 172 | #calculate and append crc 173 | xorCrc = 0 174 | for byte in dataWriteCommand: 175 | xorCrc ^= byte 176 | dataWriteCommand += b'\x00' 177 | dataWriteCommand.append(xorCrc) 178 | await self._waitForRxOrRetry(dataWriteCommand) 179 | if(self.rxEepromAddress != address.to_bytes(2, 'big')): 180 | raise ValueError(f"recieved packet address {self.rxEepromAddress} does not match the written address {address.to_bytes(2, 'big')}") 181 | if(self.rxPacketType != bytearray.fromhex("81c0")): 182 | raise ValueError("Invalid packet type in eeprom write") 183 | return 184 | 185 | async def _readBlockEeprom(self, address, blocksize): 186 | dataReadCommand = bytearray.fromhex("080100") 187 | dataReadCommand += address.to_bytes(2, 'big') 188 | dataReadCommand += blocksize.to_bytes(1, 'big') 189 | #calculate and append crc 190 | xorCrc = 0 191 | for byte in dataReadCommand: 192 | xorCrc ^= byte 193 | dataReadCommand += b'\x00' 194 | dataReadCommand.append(xorCrc) 195 | await self._waitForRxOrRetry(dataReadCommand) 196 | if(self.rxEepromAddress != address.to_bytes(2, 'big')): 197 | raise ValueError(f"revieved packet address {self.rxEepromAddress} does not match requested address {address.to_bytes(2, 'big')}") 198 | if(self.rxPacketType != bytearray.fromhex("8100")): 199 | raise ValueError("Invalid packet type in eeprom read") 200 | return self.rxDataBytes 201 | 202 | async def writeContinuousEepromData(self, startAddress, bytesArrayToWrite, btBlockSize = 0x08): 203 | while(len(bytesArrayToWrite) != 0): 204 | nextSubblockSize = min(len(bytesArrayToWrite), btBlockSize) 205 | logger.debug(f"write to {hex(startAddress)} size {hex(nextSubblockSize)}") 206 | await self._writeBlockEeprom(startAddress, bytesArrayToWrite[:nextSubblockSize]) 207 | bytesArrayToWrite = bytesArrayToWrite[nextSubblockSize:] 208 | startAddress += nextSubblockSize 209 | return 210 | 211 | async def readContinuousEepromData(self, startAddress, bytesToRead, btBlockSize = 0x10): 212 | eepromBytesData = bytearray() 213 | while(bytesToRead != 0): 214 | nextSubblockSize = min(bytesToRead, btBlockSize) 215 | logger.debug(f"read from {hex(startAddress)} size {hex(nextSubblockSize)}") 216 | eepromBytesData += await self._readBlockEeprom(startAddress, nextSubblockSize) 217 | startAddress += nextSubblockSize 218 | bytesToRead -= nextSubblockSize 219 | return eepromBytesData 220 | 221 | def _callbackForUnlockChannel(self, UUID_or_intHandle, rxBytes): 222 | self.rxDataBytes = rxBytes 223 | self.rxFinishedFlag = True 224 | return 225 | 226 | async def writeNewUnlockKey(self, newKeyByteArray = examplePairingKey): 227 | if(len(newKeyByteArray) != 16): 228 | raise ValueError(f"key has to be 16 bytes long, is {len(newKeyByteArray)}") 229 | return 230 | #enable key programming mode 231 | await bleClient.start_notify(self.deviceUnlock_UUID, self._callbackForUnlockChannel) 232 | self.rxFinishedFlag = False 233 | await bleClient.write_gatt_char(self.deviceUnlock_UUID, b'\x02' + b'\x00'*16, response=True) 234 | while(self.rxFinishedFlag == False): 235 | await asyncio.sleep(0.1) 236 | deviceResponse = self.rxDataBytes 237 | if(deviceResponse[:2] != bytearray.fromhex("8200")): 238 | raise ValueError(f"Could not enter key programming mode. Has the device been started in pairing mode? Got response: {deviceResponse}") 239 | return 240 | #program new key 241 | self.rxFinishedFlag = False 242 | await bleClient.write_gatt_char(self.deviceUnlock_UUID, b'\x00' + newKeyByteArray, response=True) 243 | while(self.rxFinishedFlag == False): 244 | await asyncio.sleep(0.1) 245 | deviceResponse = self.rxDataBytes 246 | if(deviceResponse[:2] != bytearray.fromhex("8000")): 247 | raise ValueError(f"Failure to program new key. Response: {deviceResponse}") 248 | return 249 | await bleClient.stop_notify(self.deviceUnlock_UUID) 250 | logger.info(f"Paired device successfully with new key {newKeyByteArray}.") 251 | logger.info("From now on you can connect omit the -p flag, even on other PCs with different bluetooth-mac-addresses.") 252 | return 253 | 254 | async def unlockWithUnlockKey(self, keyByteArray = examplePairingKey): 255 | await bleClient.start_notify(self.deviceUnlock_UUID, self._callbackForUnlockChannel) 256 | self.rxFinishedFlag = False 257 | await bleClient.write_gatt_char(self.deviceUnlock_UUID, b'\x01' + keyByteArray, response=True) 258 | while(self.rxFinishedFlag == False): 259 | await asyncio.sleep(0.1) 260 | deviceResponse = self.rxDataBytes 261 | if(deviceResponse[:2] != bytearray.fromhex("8100")): 262 | raise ValueError(f"entered pairing key does not match stored one.") 263 | return 264 | await bleClient.stop_notify(self.deviceUnlock_UUID) 265 | return 266 | 267 | # Code change for Export2Garmin 268 | def readCsv(filename): 269 | records = [] 270 | with open(filename, mode='r', newline='', encoding='utf-8') as infile: 271 | reader = csv.DictReader(infile, delimiter=';') 272 | for oldRecordDict in reader: 273 | oldRecordDict["datetime"] = datetime.datetime.strptime(oldRecordDict["datetime"], "%d.%m.%Y %H:%M") 274 | records.append(oldRecordDict) 275 | return records 276 | def appendCsv(allRecords): 277 | for userIdx in range(len(allRecords)): 278 | oldCsvFile = pathlib.Path(f"{switch_temp_path}/omron_user{userIdx+1}.csv") 279 | datesOfNewRecords = [record["datetime"] for record in allRecords[userIdx]] 280 | if(oldCsvFile.is_file()): 281 | records = readCsv(f"{switch_temp_path}/omron_user{userIdx+1}.csv") 282 | allRecords[userIdx].extend(filter(lambda x: x["datetime"] not in datesOfNewRecords,records)) 283 | allRecords[userIdx] = sorted(allRecords[userIdx], key = lambda x: x["datetime"]) 284 | logger.info(f"writing data to omron_user{userIdx+1}.csv") 285 | with open(f"{switch_temp_path}/omron_user{userIdx+1}.csv", mode='w', newline='', encoding='utf-8') as outfile: 286 | writer = csv.DictWriter(outfile, delimiter=';', fieldnames = ["Data Status", "Unix Time", "datetime", "sys", "dia", "bpm", "mov", "ihb", "User"]) 287 | writer.writeheader() 288 | for recordDict in allRecords[userIdx]: 289 | recordDict["Data Status"] = "to_import" 290 | recordDict["Unix Time"] = int(time.mktime(recordDict["datetime"].timetuple())) 291 | recordDict["datetime"] = recordDict["datetime"].strftime("%d.%m.%Y %H:%M") 292 | recordDict["User"] = (f"user{userIdx+1}") 293 | writer.writerow(recordDict) 294 | 295 | # Code change for Export2Garmin 296 | async def selectBLEdevices(adapter): 297 | print("Select your Omron device from the list below...") 298 | while(True): 299 | devices = await bleak.BleakScanner.discover(adapter=adapter, return_adv=True) 300 | devices = list(sorted(devices.items(), key = lambda x: x[1][1].rssi, reverse=True)) 301 | tableEntries = [] 302 | tableEntries.append(["ID", "MAC", "NAME", "RSSI"]) 303 | for deviceIdx, (macAddr, (bleDev, advData)) in enumerate(devices): 304 | tableEntries.append([deviceIdx, macAddr, bleDev.name, advData.rssi]) 305 | print(terminaltables.AsciiTable(tableEntries).table) 306 | res = input("Enter ID or just press Enter to rescan.\n") 307 | if(res.isdigit() and int(res) in range(len(devices))): 308 | break 309 | return devices[int(res)][0] 310 | 311 | async def main(): 312 | global bleClient 313 | global deviceSpecific 314 | parser = argparse.ArgumentParser(description="python tool to read the records of omron blood pressure instruments") 315 | parser.add_argument('-d', "--device", required="true", type=ascii, help="Device name (e.g. HEM-7322T-D).") 316 | parser.add_argument("--loggerDebug", action="store_true", help="Enable verbose logger output") 317 | parser.add_argument("-p", "--pair", action="store_true", help="Programm the pairing key into the device. Needs to be done only once.") 318 | parser.add_argument("-m", "--mac", type=ascii, help="Bluetooth Mac address of the device (e.g. 00:1b:63:84:45:e6). If not specified, will scan for devices and display a selection dialog.") 319 | parser.add_argument('-n', "--newRecOnly", action="store_true", help="Considers the unread records counter and only reads new records. Resets these counters afterwards. If not enabled, all records are read and the unread counters are not cleared.") 320 | parser.add_argument('-t', "--timeSync", action="store_true", help="Update the time on the omron device by using the current system time.") 321 | 322 | # Code change for Export2Garmin 323 | parser.add_argument('-a', "--adapter", required="true", type=str, help="Choose which HCI adapter you want to scan with (e.g. hci0).") 324 | args = parser.parse_args() 325 | 326 | #setup logging 327 | handler = logging.StreamHandler() 328 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 329 | handler.setFormatter(formatter) 330 | logger.addHandler(handler) 331 | if(args.loggerDebug): 332 | logger.setLevel(logging.DEBUG) 333 | else: 334 | logger.setLevel(logging.INFO) 335 | 336 | #import device specific module 337 | if(not args.pair and not args.device): 338 | raise ValueError("When not in pairing mode, please specify your device type name with -d or --device") 339 | return 340 | if(args.device): 341 | deviceName = args.device.strip("'").strip('\"') #strip quotes around arg 342 | 343 | # Code change for Export2Garmin 344 | sys.path.insert(0, path + "/omron/deviceSpecific") 345 | try: 346 | logger.info(f"Attempt to import module for device {deviceName.lower()}") 347 | deviceSpecific = __import__(deviceName.lower()) 348 | except ImportError: 349 | raise ValueError("the device is no supported yet, you can help by contributing :)") 350 | return 351 | 352 | #select device mac address 353 | validMacRegex = re.compile(r"^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$") 354 | if(args.mac is not None): 355 | btmac = args.mac.strip("'").strip('\"') #strip quotes around arg 356 | if(validMacRegex.match(btmac) is None): 357 | raise ValueError(f"argument after -m or --mac {btmac} is not a valid mac address") 358 | return 359 | bleAddr = btmac 360 | else: 361 | print("To improve your chance of a successful connection please do the following:") 362 | print(" -remove previous device pairings in your OS's bluetooth dialog") 363 | print(" -enable bluetooth on you omron device and use the specified mode (pairing or normal)") 364 | print(" -do not accept any pairing dialog until you selected your device in the following list\n") 365 | 366 | # Code change for Export2Garmin 367 | bleAddr = await selectBLEdevices(adapter=args.adapter) 368 | bleClient = bleak.BleakClient(bleAddr, adapter=args.adapter) 369 | try: 370 | logger.info(f"Attempt connecting to {bleAddr}.") 371 | await bleClient.connect() 372 | await asyncio.sleep(0.5) 373 | await bleClient.pair(protection_level = 2) 374 | #verify that the device is an omron device by checking presence of certain bluetooth services 375 | if parentService_UUID not in [service.uuid for service in bleClient.services]: 376 | raise OSError("""Some required bluetooth attributes not found on this ble device. 377 | This means that either, you connected to a wrong device, 378 | or that your OS has a bug when reading BT LE device attributes (certain linux versions).""") 379 | return 380 | bluetoothTxRxObj = bluetoothTxRxHandler() 381 | if(args.pair): 382 | await bluetoothTxRxObj.writeNewUnlockKey() 383 | #this seems to be necessary when the device has not been paired to any device 384 | await bluetoothTxRxObj.startTransmission() 385 | await bluetoothTxRxObj.endTransmission() 386 | else: 387 | logger.info("communication started") 388 | devSpecificDriver = deviceSpecific.deviceSpecificDriver() 389 | allRecs = await devSpecificDriver.getRecords(btobj = bluetoothTxRxObj, useUnreadCounter = args.newRecOnly, syncTime = args.timeSync) 390 | logger.info("communication finished") 391 | appendCsv(allRecs) 392 | finally: 393 | logger.info("unpair and disconnect") 394 | if bleClient.is_connected: 395 | await bleClient.unpair() 396 | try: 397 | await bleClient.disconnect() 398 | except AssertionError as e: 399 | logger.error("Bleak AssertionError during disconnect. This usually happens when using the bluezdbus adapter.") 400 | logger.error("You can find the upstream issue at: https://github.com/hbldh/bleak/issues/641") 401 | logger.error(f"AssertionError details: {e}") 402 | 403 | asyncio.run(main()) --------------------------------------------------------------------------------