├── .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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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())
--------------------------------------------------------------------------------