├── doc ├── devices │ ├── xiaomi.watch.band1.md │ ├── yeelink.light.ble1.md │ ├── hhcc.plantmonitor.v1.md │ ├── yunmi.kettle.v1.md │ ├── cleargrass.sensor_ht.dk1.md │ ├── soocare.toothbrush.m1.md │ └── huami.health.scale2.md └── auth_protocol.md ├── sandbox ├── huami.health.scale2 │ ├── mibcs_test.py │ ├── mibcs_app.py │ ├── body_scales.py │ ├── body_score.py │ └── body_metrics.py └── mijia_ble_auth.py └── README.md /doc/devices/xiaomi.watch.band1.md: -------------------------------------------------------------------------------- 1 | # Mi band/Mi band 2/Mi band 3 and derivatives 2 | 3 | * For now, see https://github.com/Freeyourgadget/Gadgetbridge 4 | -------------------------------------------------------------------------------- /doc/devices/yeelink.light.ble1.md: -------------------------------------------------------------------------------- 1 | # Yeelight BLE Bedside lamp 2 | 3 | * Same design as the Mijia version (beside the metal color), but the yeelight one is older and only uses BLE, the mijia one uses wifi. 4 | * For now, see https://github.com/Marcocanc/mi-lamp-re and https://github.com/rytilahti/python-yeelightbt 5 | -------------------------------------------------------------------------------- /doc/devices/hhcc.plantmonitor.v1.md: -------------------------------------------------------------------------------- 1 | # Flora Plant Monitor 2 | 3 | ## Intro 4 | 5 | The hhcc plan monitor (sold as "Mi Flora") is a device you put in a flower pot to monitor several parameters (such as temperature, moisture and brightness) 6 | 7 | ## General informations 8 | 9 | The flora plant monitor is a flower pot monitor. They seem to use the proprietary authentication mechanism. 10 | 11 | As i don't own this device, i didn't reverse engineer it, but you can find more informations [here](https://github.com/sputnikdev/eclipse-smarthome-bluetooth-binding/issues/19) 12 | Also check out https://www.open-homeautomation.com/2016/08/23/reverse-engineering-the-mi-plant-sensor/ 13 | -------------------------------------------------------------------------------- /sandbox/huami.health.scale2/mibcs_test.py: -------------------------------------------------------------------------------- 1 | import body 2 | import sys 3 | 4 | lib = body.bodyMetrics(float(sys.argv[1]), float(sys.argv[2]), int(sys.argv[3]), sys.argv[4], int(sys.argv[5])) 5 | 6 | print("LBM = {}".format(lib.getLBMCoefficient())) 7 | print("Body fat = {}".format(lib.getFatPercentage())) 8 | print("Body fat scale = {}".format(lib.getFatPercentageScale())) 9 | print("Water = {}".format(lib.getWaterPercentage())) 10 | print("Water scale = {}".format(lib.getWaterPercentageScale())) 11 | print("Bone mass = {}".format(lib.getBoneMass())) 12 | print("Bone mass scale = {}".format(lib.getBoneMassScale())) 13 | print("Muscle mass = {}".format(lib.getMuscleMass())) 14 | print("Muscle mass scale = {}".format(lib.getMuscleMassScale())) 15 | print("Visceral fat = {}".format(lib.getVisceralFat())) 16 | print("Visceral fat scale = {}".format(lib.getVisceralFatScale())) 17 | print("BMI = {}".format(lib.getBMI())) 18 | print("BMI scale = {}".format(lib.getBMIScale())) 19 | print("BMR = {}".format(lib.getBMR())) 20 | print("BMR scale = {}".format(lib.getBMRScale())) 21 | print("Ideal weight = {}".format(lib.getIdealWeight())) 22 | print("Ideal weight scale = {}".format(lib.getIdealWeightScale())) 23 | if lib.getFatMassToIdeal()['type'] == 'to_lose': 24 | print("Fat mass to lose = {}".format(lib.getFatMassToIdeal()['mass'])) 25 | else: 26 | print("Fat mass to gain = {}".format(lib.getFatMassToIdeal()['mass'])) 27 | print("Protein percentage = {}".format(lib.getProteinPercentage())) 28 | print("Protein percentage scale = {}".format(lib.getProteinPercentageScale())) 29 | print("Body type = {}".format(lib.getBodyTypeScale()[lib.getBodyType()])) 30 | -------------------------------------------------------------------------------- /sandbox/mijia_ble_auth.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | def encrypt(key, data): 4 | # KSA 5 | key_length = len(key) 6 | S = list(range(256)) 7 | j = 0 8 | for i in range(256): 9 | j = (j + S[i] + key[i % key_length]) % 256 10 | S[i], S[j] = S[j], S[i] 11 | 12 | # PRGA 13 | j = 0 14 | keystream = [] 15 | for i in range(len(data)): 16 | i = (i + 1) % 256 17 | j = (j + S[i]) % 256 18 | S[i], S[j] = S[j], S[i] 19 | keystream.append(S[(S[i] + S[j]) % 256]) 20 | 21 | # Encrypt 22 | return bytes(a ^ b for a, b in zip(data, keystream)) 23 | 24 | class login: 25 | def __init__(self, token): 26 | self.token = token 27 | 28 | def init_ekey(self, challenge): 29 | tick = encrypt(self.token, challenge) 30 | self.ekey = bytearray(self.token) 31 | for i in range(0, 3): 32 | self.ekey[i] ^= tick[i] 33 | 34 | def get_response(self, challenge): 35 | self.init_ekey(challenge) 36 | return encrypt(self.ekey, b'\x09\xac\xbf\x93') 37 | 38 | def check_confirmation(self, confirmation): 39 | return encrypt(self.ekey, confirmation)[0:4] == b'\xc9\x58\x9a\x36' 40 | 41 | class register: 42 | def __init__(mac, productid): 43 | # The mac address is inverted 44 | self.mac = mac[::-1] 45 | self.productid = productid 46 | self.token = bytearray(random.getrandbits(8) for _ in range(12)) 47 | 48 | def mix_a(self): 49 | return bytes([self.mac[0], self.mac[2], self.mac[5], (self.productid & 0xff), (self.productid & 0xff), self.mac[4], self.mac[5], self.mac[1]]) 50 | 51 | def mix_b(self): 52 | return bytes([self.mac[0], self.mac[2], self.mac[5], ((self.productid >> 8) & 0xff), self.mac[4], self.mac[0], self.mac[5], (self.productid & 0xff)]) 53 | 54 | def get_token(self): 55 | return self.token 56 | 57 | def get_init(self): 58 | return encrypt(self.mix_a(), self.token) 59 | 60 | def check_confirmation(self, confirmation): 61 | return self.token == encrypt(self.mix_b(), encrypt(self.mix_a(), confirmation)) 62 | 63 | def get_end(self): 64 | return encrypt(self.token, b'\x92\xab\x54\xfa') 65 | -------------------------------------------------------------------------------- /doc/devices/yunmi.kettle.v1.md: -------------------------------------------------------------------------------- 1 | # Xiaomi BLE kettle 2 | 3 | ## Intro 4 | 5 | The yunmi kettles (v1 and v2, they seems to use the same protocol) are just kettles with some additional features and monitoring using BLE. 6 | 7 | These devices use the proprietary auth mechanism. 8 | 9 | WARNING: I don't own the device, hence, this documentation is theorical. If you have a kettle, contributions to correct this documentation are welcome! 10 | 11 | ## Limitations 12 | 13 | * Cannot heat water if "keep warm" is off 14 | * Cannot remotely enable "keep warm" 15 | * The maximum time for "keep warm" is 12h 16 | * If the temperature drops (eg you add cold water), "keep warm" will turn off 17 | * If the water level is low, "keep warm" will turn off 18 | * The minimum temperature for "keep warm" is 40 degrees celsius 19 | 20 | ## Services & Characteristics 21 | 22 | * Mi service (uuid=0000fe95-0000-1000-8000-00805f9b34fb) 23 | * * Token characteristic (uuid=00000001-0000-1000-8000-00805f9b34fb), props=WRITE NOTIFY handle=45 24 | * * Firmware version characteristic (uuid=00000004-0000-1000-8000-00805f9b34fb), props=READ handle=50 25 | * * Event characteristic (uuid=00000010-0000-1000-8000-00805f9b34fb), props=WRITE handle=52 26 | * * Setup characteristic (uuid=aa01), props=WRITE handle= 27 | * * Status characteristic (uuid=aa02), props=NOTIFY handle= 28 | * * Time characteristic (uuid=aa04), props=READ WRITE handle= 29 | * * Boil mode characteristic (uuid=aa05), props=READ WRITE handle= 30 | * * MCU Version characteristic (uuid=2a28), props=READ handle= 31 | 32 | ## Protocol 33 | 34 | ### Setup characteristic 35 | 36 | * uint8: type (0 = boil and cool down to set temperature, 1 = heat to set temperature) 37 | * uint8: temperature (40 to 95) 38 | 39 | ### Boil mode characteristic 40 | 41 | * uint8: turn off after boil (boolean) 42 | 43 | ### Time characteristic 44 | 45 | * uint8: time to keep warm (0 to 12, multiplied by 2) 46 | 47 | ### MCU Version characteristic 48 | 49 | * char[]: version 50 | 51 | ### Status characteristic 52 | 53 | * uint8: action (0 = idle, 1 = heating, 2 = cooling, 3 = keeping warm) 54 | * uint8: mode (1 = boil, 2 = keep warm, 255 = none) 55 | * uint16: unknown 56 | * uint8: keep warm temperature (40 to 95 degrees celsius) 57 | * uint8: current temperature (0 to 100 degrees celsius) 58 | * uint8: keep warm type (0 = boil and cool down to set temperature, 1 = heat to set temperature) 59 | * uint16: keep warm time (minutes since keep warm was enabled) 60 | 61 | 62 | ## Thanks 63 | This documentation is largely based on https://github.com/aprosvetova/xiaomi-kettle and https://github.com/drndos/mikettle, a huge thanks to them! 64 | -------------------------------------------------------------------------------- /sandbox/huami.health.scale2/mibcs_app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import random 4 | from struct import * 5 | from bluepy import btle 6 | 7 | import body 8 | 9 | 10 | class miScale(btle.DefaultDelegate): 11 | def __init__(self): 12 | btle.DefaultDelegate.__init__(self) 13 | 14 | self.address = sys.argv[1] 15 | self.height = int(sys.argv[2]) 16 | self.age = int(sys.argv[3]) 17 | self.sex = sys.argv[4] 18 | 19 | def handleDiscovery(self, dev, isNewDev, isNewData): 20 | if dev.addr == self.address: 21 | for (adType, desc, value) in dev.getScanData(): 22 | if adType == 22: 23 | data = bytes.fromhex(value[4:]) 24 | ctrlByte0 = data[0] 25 | ctrlByte1 = data[1] 26 | 27 | emptyLoad = ctrlByte1 & (1<<7) 28 | isStabilized = ctrlByte1 & (1<<5) 29 | hasImpedance = ctrlByte1 & (1<<1) 30 | 31 | 32 | if emptyLoad: 33 | print("(no load)") 34 | 35 | print("New packet") 36 | if isStabilized: 37 | print("New stabilized weight") 38 | if hasImpedance: 39 | print("New impedance") 40 | 41 | print("\t Control bytes = {0:08b}/{1:08b}".format(ctrlByte0, ctrlByte1)) 42 | print("\t Date = {}/{}/{} {}:{}:{}".format(int(data[5]), int(data[4]), int((data[3] << 8) | data[2]), int(data[6]), int(data[7]), int(data[8]))) 43 | 44 | impedance = ((data[10] & 0xFF) << 8) | (data[9] & 0xFF) 45 | weight = (((data[12] & 0xFF) << 8) | (data[11] & 0xFF)) / 200.0 46 | 47 | print("\t impedance is {}".format(impedance)) 48 | print("\t weight is {}".format(weight)) 49 | 50 | if hasImpedance: 51 | lib = body.bodyMetrics(weight, self.height, self.age, self.sex, impedance) 52 | 53 | print("\t\tLBM = {}".format(lib.getLBMCoefficient())) 54 | print("\t\tBody fat = {}".format(lib.getFatPercentage())) 55 | print("\t\tBody fat scale = {}".format(lib.getFatPercentageScale())) 56 | print("\t\tWater = {}".format(lib.getWaterPercentage())) 57 | print("\t\tWater scale = {}".format(lib.getWaterPercentageScale())) 58 | print("\t\tBone mass = {}".format(lib.getBoneMass())) 59 | print("\t\tBone mass scale = {}".format(lib.getBoneMassScale())) 60 | print("\t\tMuscle mass = {}".format(lib.getMuscleMass())) 61 | print("\t\tMuscle mass scale = {}".format(lib.getMuscleMassScale())) 62 | print("\t\tVisceral fat = {}".format(lib.getVisceralFat())) 63 | print("\t\tVisceral fat scale = {}".format(lib.getVisceralFatScale())) 64 | print("\t\tBMI = {}".format(lib.getBMI())) 65 | print("\t\tBMI scale = {}".format(lib.getBMIScale())) 66 | print("\t\tBMR = {}".format(lib.getBMR())) 67 | print("\t\tBMR scale = {}".format(lib.getBMRScale())) 68 | print("\t\tIdeal weight = {}".format(lib.getIdealWeight())) 69 | return 70 | 71 | elif adType == 1 or adType == 2 or adType == 9: 72 | continue 73 | elif adType == 255: 74 | continue 75 | else: 76 | print("=> new unknown packet: type={} data={}".format(adType, value)) 77 | 78 | def run(self): 79 | scanner = btle.Scanner() 80 | scanner.withDelegate(self) 81 | while True: 82 | scanner.start() 83 | scanner.process(1) 84 | scanner.stop() 85 | 86 | scale = miScale() 87 | scale.run() 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bluetooth/BLE 2 | General information on how BLE is used in Xiaomi's devices. 3 | 4 | ## General context 5 | 6 | Xiaomi (and it's many, many sub-brands) uses several wireless protocols for their devices, mainly zigbee (aqara devices), BLE (many "standalone" devices and smartbands/smartwatches from huami and amazfit), and wifi (usually their "biggest" devices such as the vacuum cleaner, and some yeelight devices, basically, what's guaranteed to have enough power at all times) 7 | 8 | Xiaomi is known to share it's logistics division with it's sub-OEMs, and also it's "Mi Home" ecosystem. They also seems to share some proprietary protocols, especially in BLE and zigbee. 9 | 10 | ## BLE notes 11 | 12 | * Almost every devices implement a similar (if not the same) firmware upgrade logic, there seems to have differences as some uses Nordic semiconductors' DFU service, and other seems to only mimic parts of it. I guess they started by using Nordic microchips and then expanded, and did reproduce the Nordic's DFU mode to their other devices so they still can share most of the code. 13 | * Some devices implement authentication, even if it's "optional" as you can still talk to the device for some time (around a minute) before it drops the connection. The authentication is encrypted using a custom JNI, "libblecipher.so". These devices seems to include the Flora plant monitor, the Soocare toothbrush, and the temperature sensor, it isn't excluded that they use the same mechanism in other devices such as the mi bands. 14 | * Xiaomi does have 3 16b UUIDs, but i've only seen `fe95` being used so far. 15 | * Huami have 2 16b UUIDs used by the mi bands: 0xFEE0 and 0xFEE1 16 | 17 | ## Devices covered (Aka. what's coming sooner or later) 18 | 19 | If you have a xiaomi ecosystem device and did reverse engineer it, feel free to contribute. For now, for obvious reasons, i'll focus on some devices i already own: 20 | 21 | | Device name | Device ID | Documentation | Status | 22 | |--------------------------------------|----------------------------|----------------------------------------------------|--------------------| 23 | | Mi Body Composition Scale | `huami.health.scale2`\* | [Doc](doc/devices/huami.health.scale2.md) | Done | 24 | | Mi toothbrush | `soocare.toothbrush.m1` | [Doc](doc/devices/soocare.toothbrush.m1.md) | Done | 25 | | Yeelight Bedside lamp | `yeelink.light.ble1` | [Doc](doc/devices/yeelink.light.ble1.md) | TODO | 26 | | Mi LED Desk lamp | `yeelink.light.lamp1` | [Doc](doc/devices/yeelink.light.lamp1.md) | TODO | 27 | | Yeelight Candela | `yeelink.light.mb2grp` | [Doc](doc/devices/yeelink.light.mb2grp.md) | TODO | 28 | | Mi Kettle V1 | `yunmi.kettle.v1` | [Doc](doc/devices/yunmi.kettle.v1.md) | TODO | 29 | | Temperature/Humidity sensor | `cleargrass.sensor_ht.dk1` | [Doc](doc/devices/cleargrass.sensor_ht.dk1.md) | WIP | 30 | | Flora plant monitor | `hhcc.plantmonitor.v1` | [Doc](doc/devices/hhcc.plantmonitor.v1.md) | TODO | 31 | | Mi Band 1 | `xiaomi.watch.band1` | [Doc](doc/devices/xiaomi.watch.band1.md) | TODO | 32 | | Mi Band 2 | `xiaomi.watch.band2` | [Doc](doc/devices/xiaomi.watch.band2.md) | TODO | 33 | | iHealth Track Blood Pressure Monitor | `ihealth.bp.550bt` | [Doc](doc/devices/ihealth.bp.550bt.md) | WIP | 34 | 35 | \* Guesstimate, it isn't in mi home, but in some other app, of the ecosysteme or OEM's one 36 | 37 | ## And for other protocols? 38 | 39 | * You can check out the awesome work of Dennis Giese [here] https://github.com/dgiese/dustcloud 40 | * Documentation in Wi to come soon 41 | -------------------------------------------------------------------------------- /doc/devices/cleargrass.sensor_ht.dk1.md: -------------------------------------------------------------------------------- 1 | # Mijia temperature/humidity monitor 2 | 3 | ## Intro 4 | 5 | This is a simple temperature + humidity sensor (amazingly called "humiture" by the OEM) with a LCD (and there seems to have an e-ink variant) screen. 6 | 7 | The device seems actually that simple, it has the nordic DFU protocol, it send it's data via advertisement, and it has some debug services/char, that's it. 8 | 9 | ## Services and characteristics 10 | 11 | * Generic Access (uuid=00001800-0000-1000-8000-00805f9b34fb) 12 | * * Device Name (uuid=00002a00-0000-1000-8000-00805f9b34fb), props=READ WRITE handle=3 13 | * * Appearance (uuid=00002a01-0000-1000-8000-00805f9b34fb), props=READ handle=5 14 | * * Peripheral Preferred Connection Parameters (uuid=00002a04-0000-1000-8000-00805f9b34fb), props=READ handle=7 15 | * Generic Attribute (uuid=00001801-0000-1000-8000-00805f9b34fb) 16 | * * Service Changed (uuid=00002a05-0000-1000-8000-00805f9b34fb), props=INDICATE handle=10 17 | * Message Service (uuid=226c0000-6476-4566-7562-66734470666d) 18 | * * Humiture (uuid=226caa55-6476-4566-7562-66734470666d), props=NOTIFY handle=14 19 | * * Message (uuid=226cbb55-6476-4566-7562-66734470666d), props=WRITE NOTIFY handle=19 20 | * Battery Service (uuid=0000180f-0000-1000-8000-00805f9b34fb) 21 | * * Battery Level (uuid=00002a19-0000-1000-8000-00805f9b34fb), props=READ NOTIFY handle=24 22 | * Device Information (uuid=0000180a-0000-1000-8000-00805f9b34fb) 23 | * * Manufacturer Name (uuid=00002a29-0000-1000-8000-00805f9b34fb), props=READ handle=28 24 | * * Model Number (uuid=00002a24-0000-1000-8000-00805f9b34fb), props=READ handle=30 25 | * * Serial Number (uuid=00002a25-0000-1000-8000-00805f9b34fb), props=READ handle=32 26 | * * Hardware Revision (uuid=00002a27-0000-1000-8000-00805f9b34fb), props=READ handle=34 27 | * * Firmware Revision (uuid=00002a26-0000-1000-8000-00805f9b34fb), props=READ handle=36 28 | * Nordic DFU (uuid=00001530-1212-efde-1523-785feabcd123) 29 | * * DFU Packet (uuid=00001532-1212-efde-1523-785feabcd123), props=WRITE NO RESPONSE handle=39 30 | * * DFU Control point (uuid=00001531-1212-efde-1523-785feabcd123), props=WRITE NOTIFY handle=41 31 | * * DFU Version (uuid=00001534-1212-efde-1523-785feabcd123), props=READ handle=44 32 | * Mi Service (uuid=0000fe95-0000-1000-8000-00805f9b34fb) 33 | * * Token characteristic (uuid=00000001-0000-1000-8000-00805f9b34fb), props=WRITE NOTIFY handle=47 34 | * * Custom characteristic (uuid=00000002-0000-1000-8000-00805f9b34fb), props=READ handle=50 35 | * * Firmware version characteristic (uuid=00000004-0000-1000-8000-00805f9b34fb), props=READ handle=52 36 | * * Event characteristic (uuid=00000010-0000-1000-8000-00805f9b34fb), props=WRITE handle=54 37 | * * Serial number characteristic (uuid=00000013-0000-1000-8000-00805f9b34fb), props=READ WRITE handle=56 38 | * * Beacon key characteristic (uuid=00000014-0000-1000-8000-00805f9b34fb), props=READ WRITE handle=58 39 | 40 | 41 | ### Message service 42 | 43 | That's the only custom service (beside the DFU and the MI service, but these aren't specific to this device), it seems to be used only in the debug activity 44 | 45 | #### Humiture characteristic 46 | 47 | Humiture = Humidity + Temperature, this isn't from me, it's from the OEM. 48 | You can subscibe to it's notifications (real time data) and the data is as such: 49 | 50 | * parse = ParseValueUtil.parseHumitureValue(datainhexstring) 51 | * temp = parse[0] # float 52 | * humidity = parse[1] # float 53 | * if parse[2] == 0.0f # which should always be the case?!? 54 | * * text = "unknown" 55 | * if parse[2] != 1.0f and parse[2] != -1.0f 56 | * * text = "near" 57 | 58 | * parseHumitureValue(String str): 59 | * * match = Pattern.compile("\\w=(-?\\d*\\.?\\d*)\\s\\w=(\\d*\\.?\\d*)").matcher(str) 60 | * * temp = match[1] 61 | * * humidity = match[2] 62 | => data = w(?)='-'?(int)'.'?(int)(space)w(?)=(int)'.'?(int) 63 | => data = `[a-zA-Z_0-9]=(-?[0-9]+.?[0-9]+)[[:space:]][a-zA-Z_0-9]=([0-9]+.?[0-9]+)` 64 | 65 | #### Message characteristic 66 | 67 | * if clicked on "Restart", send 43470006 68 | * if clicked on "screentest send 4347000104 69 | * if clicked on "screentestend" send 4347000105 70 | * You can subscribe to notifications, data: 71 | * * if data.startswith('4347'): 72 | * * * if data[4-8] = '0002': 73 | * * * * substr = data[8-10] 74 | * * * * if substr != '00' and substr != '01': 75 | * * * * * display substr1 76 | 77 | ### Broadcast data 78 | 79 | The temperature sensor mainly work with broadcast data 80 | 81 | It first check if the data starts with '95fe5020', and if the "type" is either 0d10 or 0a10 82 | * If it's 0d10, it parses the humidity and temperature from it 83 | * If it's 0a10, it simply return the bytes after it's size (it's the battery percentage) 84 | 85 | * if (str.indexOf("95FE5020") == 10): 86 | * * if (str.indexOf("0D10") == 36): 87 | * * * return parseHexTemperatureHumidity(str.substring(42, (Integer.parseInt(str.substring(40, 42), 16) * 2) + 42)); 88 | * * if (str.indexOf("0A10") == 36): 89 | * * * return new float[]{(float) Integer.parseInt(str.substring(42, 44), 16)}; 90 | 91 | parseHexTemperatureHumidity(String str): 92 | * if ("56046F".equals(str) || "56045604".equals(str)): 93 | * * return new float[]{99999.0f, 99999.0f} 94 | 95 | -------------------------------------------------------------------------------- /doc/auth_protocol.md: -------------------------------------------------------------------------------- 1 | # Xiaomi's proprietary auth protocol 2 | 3 | ## Intro 4 | 5 | Many xiaomi (or sub-brands of the mi ecosystem) uses a proprietary authentication mechanism. This is in fact from the Mi Home SDK, and i'm assuming xiaomi also provides MCU sample code for these OEMs so they can integrate easily into Mi Home. 6 | 7 | There seems to be two kind of binding: "weak" and "strong". (these are only used for server binding, not for ble directly!) 8 | 9 | ## Known devices 10 | 11 | Check `devices_list.csv` 12 | 13 | ## BLE Services and characteristics 14 | 15 | * MI Service (uuid=fe95) 16 | * * Token characteristic (uuid=0001) 17 | * * Firmware version characteristic (uuid=0004) 18 | * * Wifi AP SSID characteristic (uuid=0005) 19 | * * Wifi AP Password characteristic (uuid=0006) 20 | * * Event characteristic (uuid=0010) 21 | * * Wifi UID characteristic (uuid=0011) 22 | * * Wifi Status characteristic (uuid=0005) 23 | * * Serial Number characteristic (uuid=0013) 24 | * * Beacon Key characteristic (uuid=0014) 25 | 26 | ## Login protocol 27 | 28 | * Enable notifications for the token characteristic && check response 29 | * Send `session_start` ('\x00\xbc\x43\xcd') to the event characteristic 30 | * Should receive some data ('challenge') in a notify from the token characteristic 31 | * Compute 'tick' = encrypt(token, challenge) 32 | * Compute encryption key = token with it's 4 first bytes XORed to the first 4 bytes of tick 33 | * `session_end` = '\x09\xac\xbf\x93' 34 | * Send challenge response (= encrypt(encryption key, `session_end`)) 35 | * Should receive a confirmation (`confirmmation`) in a notify from token characteristic 36 | * To confirm, compare '\xc9\x58\x9a\x36' and encrypt(encryption key, confirmation)[0:4] 37 | 38 | ## Register protocol 39 | 40 | * Note: the mac address is reversed for the mix\* functions! 41 | * Enable notifications for the token characteristic && check reponse 42 | * Send `session_start` ('\x90\xca\x85\xde') to the event characteristic 43 | * Create a `token` (see "Generate token" below) 44 | * Write the result of `encrypt(mixA(mac_address, product_id), token)` to the token characteristic 45 | * Should receive some data ('confirmation') in a notify from the token characreristic 46 | * `token` should be equal to the result of `encrypt(mixB(mac_address, product_id), encrypt(mixA(mac_address, product_id), confirmation))`, if it's the case, continue, else, return an error 47 | * `session_end` = '\x92\xab\x54\xfa' 48 | * Send the result of `encrypt(token, session_end)` to the token characteristic to confirm the registration 49 | 50 | ## Generate token 51 | 52 | * That's how they do it in mi home: `token = md5_12('token.{}.{}'format(currentTimeMillis(), randFloat()))` 53 | * I guess it could be anything really, as long as it's 12 bytes 54 | 55 | ## Crypt functions 56 | 57 | Xiaomi for some reason (security by obscurity?) uses a native (in a JNI) implementation of RC4 and two custom functions that generate a "key" based on the mac address and the product ID. 58 | You can check out [drndos's kettler PoC](https://github.com/drndos/mi-kettle-poc/blob/master/mi-kettle.py) which is based on [aprosvetova's reverse engineering of the JNI](https://github.com/aprosvetova/xiaomi-kettle), which also contains an implementation of the RC4 of `blecipher.so` (the JNI used in mi home) 59 | You can find python implementations of the `mixA`, `mixB` and `encrypt` functions of the JNI below. 60 | 61 | ### MixA 62 | 63 | ``` python 64 | def mixA(mac, productid): 65 | return bytes([mac[0], mac[2], mac[5], (productid & 0xff), (productid & 0xff), mac[4], mac[5], mac[1]]) 66 | ``` 67 | 68 | ### MixB 69 | 70 | ``` python 71 | def mixB(mac, productid): 72 | return bytes([mac[0], mac[2], mac[5], ((productid >> 8) & 0xff), mac[4], mac[0], mac[5], (productid & 0xff)]) 73 | ``` 74 | 75 | ### Encrypt 76 | 77 | ``` python 78 | def encrypt(key, data): 79 | # KSA 80 | key_length = len(key) 81 | S = list(range(256)) 82 | j = 0 83 | for i in range(256): 84 | j = (j + S[i] + key[i % key_length]) % 256 85 | S[i], S[j] = S[j], S[i] 86 | 87 | # PRGA 88 | j = 0 89 | keystream = [] 90 | for i in range(len(data)): 91 | i = (i + 1) % 256 92 | j = (j + S[i]) % 256 93 | S[i], S[j] = S[j], S[i] 94 | keystream.append(S[(S[i] + S[j]) % 256]) 95 | 96 | # Encrypt 97 | return bytes(a ^ b for a, b in zip(data, keystream)) 98 | ``` 99 | 100 | ## Conclusion & Thanks 101 | 102 | It's a bit surprising to find such a "complex" (compared to what's the devices are doing) system for authentication, but it's actually a nice surprise to find that xiaomi didn't forgot the security on their devices. Sadly, some OEMs doesn't completly respect this security, and some devices can be accessed for some time without authentication, you'll get disconnected after a timeout eventually. 103 | There should be everything here to init and connect to some devices, without being kicked out after some time. 104 | Thanks to `aprosvetova` for her amazing reverse engineering work on the JNI and `drndos` for the python port (didn't have to mess with crypto algorithms in python yay!). 105 | Also thanks to `vkolotov` and some others i probably forgot for their initial work on the authentication mechanism. 106 | Thanks to Mijia for being nice enough to give a SDK without any obfuscation like in mi home so it was much easier to reverse engineer this protocol. 107 | -------------------------------------------------------------------------------- /sandbox/huami.health.scale2/body_scales.py: -------------------------------------------------------------------------------- 1 | class bodyScales: 2 | def __init__(self, age, height, sex, weight, scaleType='xiaomi'): 3 | self.age = age 4 | self.height = height 5 | self.sex = sex 6 | self.weight = weight 7 | 8 | if scaleType == 'xiaomi': 9 | self.scaleType = 'xiaomi' 10 | else: 11 | self.scaleType = 'holtek' 12 | 13 | # Get BMI scale 14 | def getBMIScale(self): 15 | if self.scaleType == 'xiaomi': 16 | # Amazfit/new mi fit 17 | #return [18.5, 24, 28] 18 | # Old mi fit // amazfit for body figure 19 | return [18.5, 25.0, 28.0, 32.0] 20 | elif self.scaleType == 'holtek': 21 | return [18.5, 25.0, 30.0] 22 | 23 | # Get fat percentage scale 24 | def getFatPercentageScale(self): 25 | # The included tables where quite strange, maybe bogus, replaced them with better ones... 26 | if self.scaleType == 'xiaomi': 27 | scales = [ 28 | {'min': 0, 'max': 11, 'female': [12.0, 21.0, 30.0, 34.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 29 | {'min': 12, 'max': 13, 'female': [15.0, 24.0, 33.0, 37.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 30 | {'min': 14, 'max': 15, 'female': [18.0, 27.0, 36.0, 40.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 31 | {'min': 16, 'max': 17, 'female': [20.0, 28.0, 37.0, 41.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 32 | {'min': 18, 'max': 39, 'female': [21.0, 28.0, 35.0, 40.0], 'male': [11.0, 17.0, 22.0, 27.0]}, 33 | {'min': 40, 'max': 59, 'female': [22.0, 29.0, 36.0, 41.0], 'male': [12.0, 18.0, 23.0, 28.0]}, 34 | {'min': 60, 'max': 100, 'female': [23.0, 30.0, 37.0, 42.0], 'male': [14.0, 20.0, 25.0, 30.0]}, 35 | ] 36 | 37 | elif self.scaleType == 'holtek': 38 | scales = [ 39 | {'min': 0, 'max': 20, 'female': [18, 23, 30, 35], 'male': [8, 14, 21, 25]}, 40 | {'min': 21, 'max': 25, 'female': [19, 24, 30, 35], 'male': [10, 15, 22, 26]}, 41 | {'min': 26, 'max': 30, 'female': [20, 25, 31, 36], 'male': [11, 16, 21, 27]}, 42 | {'min': 31, 'max': 35, 'female': [21, 26, 33, 36], 'male': [13, 17, 25, 28]}, 43 | {'min': 46, 'max': 40, 'female': [22, 27, 34, 37], 'male': [15, 20, 26, 29]}, 44 | {'min': 41, 'max': 45, 'female': [23, 28, 35, 38], 'male': [16, 22, 27, 30]}, 45 | {'min': 46, 'max': 50, 'female': [24, 30, 36, 38], 'male': [17, 23, 29, 31]}, 46 | {'min': 51, 'max': 55, 'female': [26, 31, 36, 39], 'male': [19, 25, 30, 33]}, 47 | {'min': 56, 'max': 100, 'female': [27, 32, 37, 40], 'male': [21, 26, 31, 34]}, 48 | ] 49 | 50 | for scale in scales: 51 | if self.age >= scale['min'] and self.age <= scale['max']: 52 | return scale[self.sex] 53 | 54 | # Get muscle mass scale 55 | def getMuscleMassScale(self): 56 | if self.scaleType == 'xiaomi': 57 | scales = [ 58 | {'min': {'male': 170, 'female': 160}, 'female': [36.5, 42.6], 'male': [49.4, 59.5]}, 59 | {'min': {'male': 160, 'female': 150}, 'female': [32.9, 37.6], 'male': [44.0, 52.5]}, 60 | {'min': {'male': 0, 'female': 0}, 'female': [29.1, 34.8], 'male': [38.5, 46.6]}, 61 | ] 62 | elif self.scaleType == 'holtek': 63 | scales = [ 64 | {'min': {'male': 170, 'female': 170}, 'female': [36.5, 42.5], 'male': [49.5, 59.4]}, 65 | {'min': {'male': 160, 'female': 160}, 'female': [32.9, 37.5], 'male': [44.0, 52.4]}, 66 | {'min': {'male': 0, 'female': 0}, 'female': [29.1, 34.7], 'male': [38.5, 46.5]} 67 | ] 68 | 69 | for scale in scales: 70 | if self.height >= scale['min'][self.sex]: 71 | return scale[self.sex] 72 | 73 | 74 | 75 | # Get water percentage scale 76 | def getWaterPercentageScale(self): 77 | if self.scaleType == 'xiaomi': 78 | if self.sex == 'male': 79 | return [55.0, 65.1] 80 | elif self.sex == 'female': 81 | return [45.0, 60.1] 82 | elif self.scaleType == 'holtek': 83 | return [53, 67] 84 | 85 | 86 | # Get visceral fat scale 87 | def getVisceralFatScale(self): 88 | # Actually the same in mi fit/amazfit and holtek's sdk 89 | return [10.0, 15.0] 90 | 91 | 92 | # Get bone mass scale 93 | def getBoneMassScale(self): 94 | if self.scaleType == 'xiaomi': 95 | scales = [ 96 | {'male': {'min': 75.0, 'scale': [2.0, 4.2]}, 'female': {'min': 60.0, 'scale': [1.8, 3.9]}}, 97 | {'male': {'min': 60.0, 'scale': [1.9, 4.1]}, 'female': {'min': 45.0, 'scale': [1.5, 3.8]}}, 98 | {'male': {'min': 0.0, 'scale': [1.6, 3.9]}, 'female': {'min': 0.0, 'scale': [1.3, 3.6]}}, 99 | ] 100 | 101 | for scale in scales: 102 | if self.weight >= scale[self.sex]['min']: 103 | return scale[self.sex]['scale'] 104 | 105 | elif self.scaleType == 'holtek': 106 | scales = [ 107 | {'female': {'min': 60, 'optimal': 2.5}, 'male': {'min': 75, 'optimal': 3.2}}, 108 | {'female': {'min': 45, 'optimal': 2.2}, 'male': {'min': 69, 'optimal': 2.9}}, 109 | {'female': {'min': 0, 'optimal': 1.8}, 'male': {'min': 0, 'optimal': 2.5}} 110 | ] 111 | 112 | for scale in scales: 113 | if self.weight >= scale[self.sex]['min']: 114 | return [scale[self.sex]['optimal']-1, scale[self.sex]['optimal']+1] 115 | 116 | 117 | # Get BMR scale 118 | def getBMRScale(self): 119 | if self.scaleType == 'xiaomi': 120 | coefficients = { 121 | 'male': {30: 21.6, 50: 20.07, 100: 19.35}, 122 | 'female': {30: 21.24, 50: 19.53, 100: 18.63} 123 | } 124 | elif self.scaleType == 'holtek': 125 | coefficients = { 126 | 'female': {12: 34, 15: 29, 17: 24, 29: 22, 50: 20, 120: 19}, 127 | 'male': {12: 36, 15: 30, 17: 26, 29: 23, 50: 21, 120: 20} 128 | } 129 | 130 | for age, coefficient in coefficients[self.sex].items(): 131 | if self.age < age: 132 | return [self.weight * coefficient] 133 | 134 | 135 | # Get protein scale (hardcoded in mi fit) 136 | def getProteinPercentageScale(self): 137 | # Actually the same in mi fit and holtek's sdk 138 | return [16, 20] 139 | 140 | # Get ideal weight scale (BMI scale converted to weights) 141 | def getIdealWeightScale(self): 142 | scale = [] 143 | for bmiScale in self.getBMIScale(): 144 | scale.append((bmiScale*self.height)*self.height/10000) 145 | return scale 146 | 147 | # Get Body Score scale 148 | def getBodyScoreScale(self): 149 | # very bad, bad, normal, good, better 150 | return [50.0, 60.0, 80.0, 90.0] 151 | 152 | # Return body type scale 153 | def getBodyTypeScale(self): 154 | return ['obese', 'overweight', 'thick-set', 'lack-exerscise', 'balanced', 'balanced-muscular', 'skinny', 'balanced-skinny', 'skinny-muscular' 155 | -------------------------------------------------------------------------------- /sandbox/huami.health.scale2/body_score.py: -------------------------------------------------------------------------------- 1 | 2 | # Reverse engineered from amazfit's app (also known as Mi Fit) 3 | from body_scales import bodyScales 4 | class bodyScore: 5 | 6 | def __init__(self, age, sex, height, weight, bmi, bodyfat, muscle, water, visceral_fat, bone, basal_metabolism, protein): 7 | self.age = age 8 | self.sex = sex 9 | self.height = height 10 | self.weight = weight 11 | self.bmi = bmi 12 | self.bodyfat = bodyfat 13 | self.muscle = muscle 14 | self.water = water 15 | self.visceral_fat = visceral_fat 16 | self.bone = bone 17 | self.basal_metabolism = basal_metabolism 18 | self.protein = protein 19 | self.scales = bodyScales(age, height, sex, weight) 20 | 21 | def getBodyScore(self): 22 | score = 100 23 | score -= self.getBmiDeductScore() 24 | score -= self.getBodyFatDeductScore() 25 | score -= self.getMuscleDeductScore() 26 | score -= self.getWaterDeductScore() 27 | score -= self.getVisceralFatDeductScore() 28 | score -= self.getBoneDeductScore() 29 | score -= self.getBasalMetabolismDeductScore() 30 | if self.protein: 31 | score -= self.getProteinDeductScore() 32 | return score 33 | 34 | def getMalus(self, data, min_data, max_data, max_malus, min_malus): 35 | result = ((data - max_data) / (min_data - max_data)) * float(max_malus - min_malus) 36 | if result >= 0.0: 37 | return result 38 | return 0.0 39 | 40 | def getBmiDeductScore(self): 41 | if not self.height >= 90: 42 | # "BMI is not reasonable 43 | return 0.0 44 | 45 | bmi_low = 15.0 46 | bmi_verylow = 14.0 47 | bmi_normal = 18.5 48 | bmi_overweight = 28.0 49 | bmi_obese = 32.0 50 | fat_scale = self.scales.getFatPercentageScale() 51 | 52 | # Perfect range (bmi >= 18.5 and bodyfat not high for adults, bmi >= 15.0 for kids 53 | if self.bmi >= 18.5 and self.age >= 18 and self.bodyfat < fat_scale[2]: 54 | return 0.0 55 | elif self.bmi >= bmi_verylow and self.age < 18 and self.bodyfat < fat_scale[2]: 56 | return 0.0 57 | 58 | # Extremely skinny (bmi < 14) 59 | elif self.bmi <= bmi_verylow: 60 | return 30.0 61 | # Too skinny (bmi between 14 and 15) 62 | elif self.bmi > bmi_verylow and self.bmi < bmi_low: 63 | return self.getMalus(self.bmi, bmi_verylow, bmi_low, 30, 15) + 15.0 64 | # Skinny (for adults, between 15 and 18.5) 65 | elif self.bmi >= bmi_low and self.bmi < bmi_normal and self.age >= 18: 66 | return self.getMalus(self.bmi, 15.0, 18.5, 15, 5) + 5.0 67 | 68 | # Normal or high bmi but too much bodyfat 69 | elif ((self.bmi >= bmi_low and self.age < 18) or (self.bmi >= bmi_normal and self.age >= 18)) and self.bodyfat >= fat_scale[2]: 70 | # Obese 71 | if self.bmi >= bmi_obese: 72 | return 10.0 73 | # Overweight 74 | if self.bmi > bmi_overweight: 75 | return self.getMalus(self.bmi, 28.0, 25.0, 5, 10) + 5.0 76 | else: 77 | return 0.0 78 | 79 | def getBodyFatDeductScore(self): 80 | scale = self.scales.getFatPercentageScale() 81 | 82 | if self.sex == 'male': 83 | best = scale[2] - 3.0 84 | elif self.sex == 'female': 85 | best = scale[2] - 2.0 86 | 87 | # Slighly low in fat or low part or normal fat 88 | if self.bodyfat >= scale[0] and self.bodyfat < best: 89 | return 0.0 90 | elif self.bodyfat >= scale[3]: 91 | return 20.0 92 | else: 93 | # Sightly high body fat 94 | if self.bodyfat < scale[3]: 95 | return self.getMalus(self.bodyfat, scale[3], scale[2], 20, 10) + 10.0 96 | 97 | # High part of normal fat 98 | elif self.bodyfat <= normal[2]: 99 | return self.getMalus(self.bodyfat, scale[2], best, 3, 9) + 3.0 100 | 101 | # Very low in fat 102 | elif self.bodyfat < normal[0]: 103 | return self.getMalus(self.bodyfat, 1.0, scale[0], 3, 10) + 3.0 104 | 105 | 106 | def getMuscleDeductScore(self): 107 | scale = self.scales.getMuscleMassScale() 108 | 109 | # For some reason, there's code to return self.calculate(muscle, normal[0], normal[0]+2.0, 3, 5) + 3.0 110 | # if your muscle is between normal[0] and normal[0] + 2.0, but it's overwritten with 0.0 before return 111 | if self.muscle >= scale[0]: 112 | return 0.0 113 | elif self.muscle < (scale[0] - 5.0): 114 | return 10.0 115 | else: 116 | return self.getMalus(self.muscle, scale[0] - 5.0, scale[0], 10, 5) + 5.0 117 | 118 | # No malus = normal or good; maximum malus (10.0) = less than normal-5.0; 119 | # malus = between 5 and 10, on your water being between normal-5.0 and normal 120 | def getWaterDeductScore(self): 121 | scale = self.scales.getWaterPercentageScale() 122 | 123 | if self.water >= scale[0]: 124 | return 0.0 125 | elif self.water <= (scale[0] - 5.0): 126 | return 10.0 127 | else: 128 | return self.getMalus(self.water, scale[0] - 5.0, scale[0], 10, 5) + 5.0 129 | 130 | # No malus = normal; maximum malus (15.0) = very high; malus = between 10 and 15 131 | # with your visceral fat in your high range 132 | def getVisceralFatDeductScore(self): 133 | scale = self.scales.getVisceralFatScale() 134 | 135 | if self.visceral_fat < scale[0]: 136 | # For some reason, the original app would try to 137 | # return 3.0 if vfat == 8 and 5.0 if vfat == 9 138 | # but i's overwritten with 0.0 anyway before return 139 | return 0.0 140 | elif self.visceral_fat >= scale[1]: 141 | return 15.0 142 | else: 143 | return self.getMalus(self.visceral_fat, scale[1], scale[0], 15, 10) + 10.0 144 | 145 | def getBoneDeductScore(self): 146 | scale = self.scales.getBoneMassScale() 147 | 148 | if self.bone >= scale[0]: 149 | return 0.0 150 | elif self.bone <= (scale[0] - 0.3): 151 | return 10.0 152 | else: 153 | return self.getMalus(self.bone, scale[0] - 0.3, scale[0], 10, 5) + 5.0 154 | 155 | def getBasalMetabolismDeductScore(self): 156 | # Get normal BMR 157 | normal = self.scales.getBMRScale()[0] 158 | 159 | if self.basal_metabolism >= normal: 160 | return 0.0 161 | elif self.basal_metabolism <= (normal - 300): 162 | return 6.0 163 | else: 164 | # It's really + 5.0 in the app, but it's probably a mistake, should be 3.0 165 | return self.getMalus(self.basal_metabolism, normal - 300, normal, 6, 3) + 5.0 166 | 167 | 168 | # Get protein percentage malus 169 | def getProteinDeductScore(self): 170 | # low: 10,16; normal: 16,17 171 | # Check limits 172 | if self.protein > 17.0: 173 | return 0.0 174 | elif self.protein < 10.0: 175 | return 10.0 176 | else: 177 | # Return values for low proteins or normal proteins 178 | if self.protein <= 16.0: 179 | return self.getMalus(self.protein, 10.0, 16.0, 10, 5) + 5.0 180 | elif self.protein <= 17.0: 181 | return self.getMalus(self.protein, 16.0, 17.0, 5, 3) + 3.0 182 | -------------------------------------------------------------------------------- /doc/devices/soocare.toothbrush.m1.md: -------------------------------------------------------------------------------- 1 | # Soocare mi toothbrush 2 | 3 | ## General infos 4 | 5 | This is the mi branded toothbrush, actually made by soocare. Another known toothbrush of their is the X3, which may or may not use the same code base. 6 | 7 | The toothbrush is a standard electric toothbrush, with some BLE connectivity to set some custom settings, and get some data, such as the "score". 8 | 9 | ## Settings and informations 10 | 11 | ### Settings 12 | 13 | * Anti splash protection (should wait 10 seconds before turning on the motor) on/off 14 | * Brushing duration: 2.0 minutes or 2.5 minutes 15 | * (custom) Brushing mode: beginner, gentle, standard or enhanced 16 | * Additional features on/off 17 | * (additional) Features: "30s extra whitening", "30s gum care" or "10s tongue cleaning". Even if the title suggest a multiple choice, you can actually only select one 18 | 19 | 20 | ### Informations 21 | 22 | * Battery percentage 23 | * History of brushing: 24 | * * Score 25 | * * Brushing duration 26 | * * Coverage 27 | * * Evenness 28 | * Toothbrush head remaining (no sensor, you have to reset it on the phone) 29 | 30 | The brush also seems to know "where" did you brush your teeth as the app seems to give advices such as where to brush more. They also advertise that the brush "monitors six areas of the upper and lower teeth" (basically, front, left right for upper and lower teeth) 31 | They do have historical data. 32 | 33 | I would guess that for every historic data, the brush store the time for each area, and that the score, coverage and evenness is calculated on the phone (a little bit like the scale) 34 | 35 | 36 | ## Protocol 37 | 38 | * The toothbrush uses the proprietary xiaomi authentication mechanism (or at least one of them) 39 | * The toothbrush also uses nordic' UART and DFU services. 40 | 41 | ### Services and characteristics 42 | 43 | * Generic access (uuid=00001800-0000-1000-8000-00805f9b34fb) 44 | * * Device name (uuid=00002a00-0000-1000-8000-00805f9b34fb), props=READ WRITE handle=3 45 | * * Appearance (uuid=00002a01-0000-1000-8000-00805f9b34fb), props=READ handle=5 46 | * * Peripheral Preferred Connection Parameters (uuid=00002a04-0000-1000-8000-00805f9b34fb), props=READ handle=7 47 | * Device Information (uuid=0000180a-0000-1000-8000-00805f9b34fb) 48 | * * Manufacturer Name String (uuid=00002a29-0000-1000-8000-00805f9b34fb), props=READ handle=20 49 | * * Model Number String (uuid=00002a24-0000-1000-8000-00805f9b34fb), props=READ handle=2 50 | * * Serial Number String (uuid=00002a25-0000-1000-8000-00805f9b34fb), props=READ handle=24 51 | * * Hardware Revision String (uuid=00002a27-0000-1000-8000-00805f9b34fb), props=READ handle=26 52 | * * Firmware Revision String (uuid=00002a26-0000-1000-8000-00805f9b34fb), props=READ handle=28 53 | * * System ID (uuid=00002a23-0000-1000-8000-00805f9b34fb), props=READ handle=30 54 | * Battery Service 55 | * * Battery Level (uuid=00002a19-0000-1000-8000-00805f9b34fb), props=READ NOTIFY handle=33 56 | * Nordic UART: uuid=6e400001-b5a3-f393-e0a9-e50e24dcca9e 57 | * * RX (uuid=6e400003-b5a3-f393-e0a9-e50e24dcca9e), props=NOTIFY handle=14 58 | * * TX (uuid=6e400002-b5a3-f393-e0a9-e50e24dcca9e), props=WRITE NO RESPONSE WRITE handle=17 59 | * Nordic DFU (uuid=00001530-1212-efde-1523-785feabcd123) 60 | * * DFU Packet (uuid=00001532-1212-efde-1523-785feabcd123), props=WRITE NO RESPONSE handle=37 61 | * * DFU Control point (uuid=00001531-1212-efde-1523-785feabcd123), props=WRITE NOTIFY handle=39 62 | * * DFU Version (uuid=00001534-1212-efde-1523-785feabcd123), props=READ handle=42 63 | * Mi service (uuid=0000fe95-0000-1000-8000-00805f9b34fb) 64 | * * Token characteristic (uuid=00000001-0000-1000-8000-00805f9b34fb), props=WRITE NOTIFY handle=45 65 | * * Custom characteristic (uuid=00000002-0000-1000-8000-00805f9b34fb), props=READ handle=48 66 | * * Firmware version characteristic (uuid=00000004-0000-1000-8000-00805f9b34fb), props=READ handle=50 67 | * * Event characteristic (uuid=00000010-0000-1000-8000-00805f9b34fb), props=WRITE handle=52 68 | * * Serial number characteristic (uuid=00000013-0000-1000-8000-00805f9b34fb), props=READ WRITE handle=54 69 | * * Beacon key characteristic (uuid=00000014-0000-1000-8000-00805f9b34fb), props=READ WRITE handle=56 70 | 71 | ### Custom services/chars 72 | 73 | * There's 1 custom characteristic, all under the xiaomi's custom service 74 | * It's read only, and never used in the app 75 | 76 | ### Advertisement data 77 | 78 | * Doesn't seems to be used 79 | 80 | ### Protocol 81 | 82 | * The protocol is actually the xiaomi's proprietary authentication mechanism, and then everything happens in the UART adapter 83 | * The UART is divided in subcommands 84 | 85 | #### UART Protocol 86 | 87 | * The UART port is "divided" in commands 88 | * The format for commands is: 89 | * * uint16: id of the command 90 | * * uint16: size of the data 91 | * * uint16: Frame number (the device doesn't seems to care much) 92 | * * uint16: CRC16 (CCITT-FALSE algorithm) of the first 6 bytes 93 | * * uint8[size]: data 94 | * "Set" commands receive an acknowledge with size=0x01 and data=0x00 if ok, 0x01 if error 95 | * "Get commands" doesn't have parameters, so tx size is always = 0x00 96 | 97 | #### UART Commands 98 | 99 | * 0x01: Set brushing time, tx size=0x04, format: 100 | * * uint16: brushing time (0x78 for 120s/2mn, 0x96 for 150s/2.5mn) 101 | * * uint16: extra time 102 | * 0x02: Get history data, tx size=0x00, return format if there's an entry, size=0x01, data=0x00 else: 103 | * * uint16[6]: timestamp 104 | * * uint16: number of samples for normal brushing time (one sample per second) 105 | * * uint16: number of samples for additional feature time (one sample per second) 106 | * * uint8[]: array of samples for normal brushing, an uint8 per second, seemingly an number per zone 107 | * * uint8[]: array of samples for additional feature, all zeroes (does not count for the score) 108 | * * uint16: unknown, always {0x00, 0x00} 109 | * 0x03: Set date/time, tx size=0x0c, format: 110 | * * uint32: UNIX timestamp in seconds 111 | * * uint32: Offset to UTC in seconds (raw offset from the phone) 112 | * * uint32: uknown, always 0x000000 it seems 113 | * * Note: It seems the app doesn't care about that, so you can just set the timestamp + all zeroes (UTC time) 114 | * 0x04: DFU/Firmware update, tx size=0 115 | * 0x05: Get battery level, tx size=0x00, return format: 116 | * * uint8: battery percentage 117 | * 0x06: Get firmware and hardware versions, tx size=0x00, return format: 118 | * * uint8[5]: firmware version, null terminated string 119 | * * uint8[5]: hardware version, null terminated string 120 | * 0x07: Set anti splash protection, tx size=0x01, format: 121 | * * uint8: enable/disable boolean, 0x00 = disable, 0x01 = enable 122 | * * It's called "Crescendo" in the app, apparently, it does makes the brush go crescendo for around 10s 123 | * 0x08: Set additional feature, tx size=0x01, format: 124 | * * uint8: feature (0x00 = disable, 0x01 = 30s extra whitening, 0x02 = 30s gum care, 0x04 = 10s tongue cleaning) 125 | * * Note: the time seems to be set in 0x01 instead, this will likely only set the force 126 | * * Note: the modes are called "no", "polish", "nurse", "tongue" in the app 127 | * 0x09: Set brushing mode, tx size=0x04, format: 128 | * * uint32: brushing mode (0x0322013c = beginner, 0x0318013c = gentle, 0x03040150 = standard, 0x03e60032 = enhanced), format: 129 | * * uint8: motor gear, 1 to 5 130 | * * uint16: motor frequency, 10 to 400 / 0x0b to 0x8f (limited to 100 to 400 in the app!) 131 | * * uint8: motor duty cycle, 1 to 99 / 0x01 to 0x63 (in percentage) 132 | * * 0x0a: Bind 133 | * * Doesn't seems to do much 134 | * * 0x0b: GetID 135 | * * Just ask to return the ID 136 | * * 0x0c: SetID 137 | * * Just set the ID 138 | 139 | #### History entry 140 | 141 | * The history entry is a timestamp, the number of seconds and the areas brushed (per second) basically (see the uart commands for more details) 142 | * The fields are pressure, level and area 143 | * Pressure (overpressure): boolean, 1 bit 144 | * Level (gear usage): 0 to 3, 2 bits 145 | * Area: 1 to 6, 5 bits, fields: 146 | * * Zone 1 is up left 147 | * * Zone 2 is down left 148 | * * Zone 3 is middle up 149 | * * Zone 4 is middle down 150 | * * Zone 5 is up right 151 | * * Zone 6 is down right 152 | * * All bits to 1 is undefined 153 | * * Every other case is "unIdentify" 154 | 155 | ## TODO 156 | 157 | * Understand the custom characteristic 00000002-0000-1000-8000-00805f9b34fb 158 | * Understand the format of the firmware version char 159 | -------------------------------------------------------------------------------- /sandbox/huami.health.scale2/body_metrics.py: -------------------------------------------------------------------------------- 1 | from math import floor 2 | from body_scales import bodyScales 3 | 4 | class bodyMetrics: 5 | def __init__(self, weight, height, age, sex, impedance): 6 | self.weight = weight 7 | self.height = height 8 | self.age = age 9 | self.sex = sex 10 | self.impedance = impedance 11 | self.scales = bodyScales(age, height, sex, weight) 12 | 13 | # Check for potential out of boundaries 14 | if self.height > 220: 15 | raise Exception("Height is too high (limit: >220cm)") 16 | elif weight < 10 or weight > 200: 17 | raise Exception("Weight is either too low or too high (limits: <10kg and >200kg)") 18 | elif age > 99: 19 | raise Exception("Age is too high (limit >99 years)") 20 | elif impedance > 3000: 21 | raise Exception("Impedance is too high (limit >3000ohm)") 22 | 23 | # Set the value to a boundary if it overflows 24 | def checkValueOverflow(self, value, minimum, maximum): 25 | if value < minimum: 26 | return minimum 27 | elif value > maximum: 28 | return maximum 29 | else: 30 | return value 31 | 32 | # Get LBM coefficient (with impedance) 33 | def getLBMCoefficient(self): 34 | lbm = (self.height * 9.058 / 100) * (self.height / 100) 35 | lbm += self.weight * 0.32 + 12.226 36 | lbm -= self.impedance * 0.0068 37 | lbm -= self.age * 0.0542 38 | return lbm 39 | 40 | # Get BMR 41 | def getBMR(self): 42 | if self.sex == 'female': 43 | bmr = 864.6 + self.weight * 10.2036 44 | bmr -= self.height * 0.39336 45 | bmr -= self.age * 6.204 46 | else: 47 | bmr = 877.8 + self.weight * 14.916 48 | bmr -= self.height * 0.726 49 | bmr -= self.age * 8.976 50 | 51 | # Capping 52 | if self.sex == 'female' and bmr > 2996: 53 | bmr = 5000 54 | elif self.sex == 'male' and bmr > 2322: 55 | bmr = 5000 56 | return self.checkValueOverflow(bmr, 500, 10000) 57 | 58 | # Get fat percentage 59 | def getFatPercentage(self): 60 | # Set a constant to remove from LBM 61 | if self.sex == 'female' and self.age <= 49: 62 | const = 9.25 63 | elif self.sex == 'female' and self.age > 49: 64 | const = 7.25 65 | else: 66 | const = 0.8 67 | 68 | # Calculate body fat percentage 69 | LBM = self.getLBMCoefficient() 70 | 71 | if self.sex == 'male' and self.weight < 61: 72 | coefficient = 0.98 73 | elif self.sex == 'female' and self.weight > 60: 74 | coefficient = 0.96 75 | if self.height > 160: 76 | coefficient *= 1.03 77 | elif self.sex == 'female' and self.weight < 50: 78 | coefficient = 1.02 79 | if self.height > 160: 80 | coefficient *= 1.03 81 | else: 82 | coefficient = 1.0 83 | fatPercentage = (1.0 - (((LBM - const) * coefficient) / self.weight)) * 100 84 | 85 | # Capping body fat percentage 86 | if fatPercentage > 63: 87 | fatPercentage = 75 88 | return self.checkValueOverflow(fatPercentage, 5, 75) 89 | 90 | # Get water percentage 91 | def getWaterPercentage(self): 92 | waterPercentage = (100 - self.getFatPercentage()) * 0.7 93 | 94 | if (waterPercentage <= 50): 95 | coefficient = 1.02 96 | else: 97 | coefficient = 0.98 98 | 99 | # Capping water percentage 100 | if waterPercentage * coefficient >= 65: 101 | waterPercentage = 75 102 | return self.checkValueOverflow(waterPercentage * coefficient, 35, 75) 103 | 104 | # Get bone mass 105 | def getBoneMass(self): 106 | if self.sex == 'female': 107 | base = 0.245691014 108 | else: 109 | base = 0.18016894 110 | 111 | boneMass = (base - (self.getLBMCoefficient() * 0.05158)) * -1 112 | 113 | if boneMass > 2.2: 114 | boneMass += 0.1 115 | else: 116 | boneMass -= 0.1 117 | 118 | # Capping boneMass 119 | if self.sex == 'female' and boneMass > 5.1: 120 | boneMass = 8 121 | elif self.sex == 'male' and boneMass > 5.2: 122 | boneMass = 8 123 | return self.checkValueOverflow(boneMass, 0.5 , 8) 124 | 125 | # Get muscle mass 126 | def getMuscleMass(self): 127 | muscleMass = self.weight - ((self.getFatPercentage() * 0.01) * self.weight) - self.getBoneMass() 128 | 129 | # Capping muscle mass 130 | if self.sex == 'female' and muscleMass >= 84: 131 | muscleMass = 120 132 | elif self.sex == 'male' and muscleMass >= 93.5: 133 | muscleMass = 120 134 | 135 | return self.checkValueOverflow(muscleMass, 10 ,120) 136 | 137 | # Get Visceral Fat 138 | def getVisceralFat(self): 139 | if self.sex == 'female': 140 | if self.weight > (13 - (self.height * 0.5)) * -1: 141 | subsubcalc = ((self.height * 1.45) + (self.height * 0.1158) * self.height) - 120 142 | subcalc = self.weight * 500 / subsubcalc 143 | vfal = (subcalc - 6) + (self.age * 0.07) 144 | else: 145 | subcalc = 0.691 + (self.height * -0.0024) + (self.height * -0.0024) 146 | vfal = (((self.height * 0.027) - (subcalc * self.weight)) * -1) + (self.age * 0.07) - self.age 147 | else: 148 | if self.height < self.weight * 1.6: 149 | subcalc = ((self.height * 0.4) - (self.height * (self.height * 0.0826))) * -1 150 | vfal = ((self.weight * 305) / (subcalc + 48)) - 2.9 + (self.age * 0.15) 151 | else: 152 | subcalc = 0.765 + self.height * -0.0015 153 | vfal = (((self.height * 0.143) - (self.weight * subcalc)) * -1) + (self.age * 0.15) - 5.0 154 | 155 | return self.checkValueOverflow(vfal, 1 ,50) 156 | 157 | # Get BMI 158 | def getBMI(self): 159 | return self.checkValueOverflow(self.weight/((self.height/100)*(self.height/100)), 10, 90) 160 | 161 | # Get ideal weight (just doing a reverse BMI, should be something better) 162 | def getIdealWeight(self, orig=True): 163 | # Uses mi fit algorithm (or holtek's one) 164 | if orig and self.sex == 'female': 165 | return (self.height - 70) * 0.6 166 | elif orig and self.sex == 'male': 167 | return (self.height - 80) * 0.7 168 | else: 169 | return self.checkValueOverflow((22*self.height)*self.height/10000, 5.5, 198) 170 | 171 | # Get fat mass to ideal (guessing mi fit formula) 172 | def getFatMassToIdeal(self): 173 | mass = (self.weight * (self.getFatPercentage() / 100)) - (self.weight * (self.scales.getFatPercentageScale()[2] / 100)) 174 | if mass < 0: 175 | return {'type': 'to_gain', 'mass': mass*-1} 176 | else: 177 | return {'type': 'to_lose', 'mass': mass} 178 | 179 | # Get protetin percentage (warn: guessed formula) 180 | def getProteinPercentage(self, orig=True): 181 | # Use original algorithm from mi fit (or legacy guess one) 182 | if orig: 183 | proteinPercentage = (self.getMuscleMass() / self.weight) * 100 184 | proteinPercentage -= self.getWaterPercentage 185 | else: 186 | proteinPercentage = 100 - (floor(self.getFatPercentage() * 100) / 100) 187 | proteinPercentage -= floor(self.getWaterPercentage() * 100) / 100 188 | proteinPercentage -= floor((self.getBoneMass()/self.weight*100) * 100) / 100 189 | 190 | return self.checkValueOverflow(proteinPercentage, 5, 32) 191 | 192 | # Get body type (out of nine possible) 193 | def getBodyType(self): 194 | if self.getFatPercentage() > self.scales.getFatPercentageScale()[2]: 195 | factor = 0 196 | elif self.getFatPercentage() < self.scales.getFatPercentageScale()[1]: 197 | factor = 2 198 | else: 199 | factor = 1 200 | 201 | if self.getMuscleMass() > self.scales.getMuscleMassScale()[1]: 202 | return 2 + (factor * 3) 203 | elif self.getMuscleMass() < self.scales.getMuscleMassScale()[0]: 204 | return (factor * 3) 205 | else: 206 | return 1 + (factor * 3) 207 | 208 | # Get Metabolic Age 209 | def getMetabolicAge(self): 210 | if self.sex == 'female': 211 | metabolicAge = (self.height * -1.1165) + (self.weight * 1.5784) + (self.age * 0.4615) + (self.impedance * 0.0415) + 83.2548 212 | else: 213 | metabolicAge = (self.height * -0.7471) + (self.weight * 0.9161) + (self.age * 0.4184) + (self.impedance * 0.0517) + 54.2267 214 | return self.checkValueOverflow(metabolicAge, 15, 80) 215 | -------------------------------------------------------------------------------- /doc/devices/huami.health.scale2.md: -------------------------------------------------------------------------------- 1 | # Mi Body Composition Scale (MIBCS) 2 | 3 | ## Intro 4 | The scale communicate over BLE, with some custom UUIDs, but actually, the data is sent over ad broadcasts, that's how mi fit get it's first data, the UUIDs are mostly for data history and configuration. 5 | 6 | Huami is using a java JNI (= native code library, originally written in c++) to calculate the metrics from some parameters (weight, impedance, age, height, sex). The JNI doesn't come from Huami, it comes from holtek, the OEM for the MCU used by the scale. holtek provide the JNI and a wrapper jar library that developpers can use to get the metrics. 7 | 8 | Mi fit doesn't use all the data provided by the JNI, it also sometime use or create it's own, for example, there's no protein calculation in the JNI, nor is the "body age" and the "body type". It also doesn't use the scales provided by the JNI as it's a pretty new feature of the holtek "sdk" and Huami already had that before, so they didn't changed to use the JNI's scales, but continued using their own (for that reason, scale data may change between the python code and what you would see on mi fit). 9 | 10 | ## Warning 11 | This is a best-effort reverse engineering of the library, not a scientific evaluation of the library, some data may not be ideal (ideal weight for example) and there may be some bugs. 12 | 13 | ## What can we do 14 | The current values we can calculate are: 15 | 16 | * LBM (using the impedance) 17 | * Fat percentage 18 | * Water percentage 19 | * Bone mass 20 | * Muscle mass 21 | * Visceral fat 22 | * BMI 23 | * BMR (basal metabolism) 24 | * Ideal weight 25 | * Fat mass to lose/gain 26 | * Protein percentage 27 | * Body type 28 | * Body age 29 | * Body Score 30 | * Scales used in Mi Fit and in the native SDK 31 | 32 | ## Body Score 33 | 34 | The body score is basically 100 - malus, where malus is the sum of a sub-score computed for every data point (bmi, muscle mass, fat percentage, etc). 35 | Each score is mostly based on the scales (where "normal" or "good" gives no malus, being way over the limits gives you maximum malus, and being in between gives you a variable malus), but sometime, it's even more precise than the scales (for example, for body fat, even being "normal" is not enough, you need to be in the first half of "normal", or even "low"). 36 | You can refer to `body_score.py` if you want more details on the algorithms. 37 | 38 | 39 | ## Scales 40 | As the JNI provide some scales, here's what they mean (remember, the numbers represent the transition between two!): 41 | 42 | * Fat percentage: very low/low/normal/high/very high 43 | * Water percentage: unsufficient/normal/good 44 | * Bone mass: unsufficient/normal/good 45 | * Muscle mass: unsufficient/normal/good 46 | * Visceral fat: normal/high/very high 47 | * BMI: underweight/normal/overweight/obese/morbidly obese 48 | * Ideal weight: underweight/normal/overweight/obese/morbidly obese 49 | * BMR: unsufficient/normal 50 | 51 | ## BLE Services and Characteristics 52 | * Generic Access (uuid=00001800-0000-1000-8000-00805f9b34fb) 53 | * * Device Name (uuid=00002a00-0000-1000-8000-00805f9b34fb), READ WRITE handle=3 54 | * * Appearance (uuid=00002a01-0000-1000-8000-00805f9b34fb), READ handle=5 55 | * * Peripheral Privacy Flag (uuid=00002a02-0000-1000-8000-00805f9b34fb), READ WRITE handle=7 56 | * * Peripheral Preferred Connection Parameters (uuid=00002a04-0000-1000-8000-00805f9b34fb), READ handle=9 57 | * * Reconnection Address (uuid=00002a03-0000-1000-8000-00805f9b34fb), READ WRITE NO RESPONSE WRITE handle=11 58 | * Generic Attribute (uuid=00001801-0000-1000-8000-00805f9b34fb) 59 | * * Service Changed (uuid=00002a05-0000-1000-8000-00805f9b34fb), READ INDICATE handle=14 60 | * Device Information (uuid=0000180a-0000-1000-8000-00805f9b34fb) 61 | * * Serial Number String (uuid=00002a25-0000-1000-8000-00805f9b34fb), READ handle=18 62 | * * Software Revision String (uuid=00002a28-0000-1000-8000-00805f9b34fb), READ handle=20 63 | * * Hardware Revision String (uuid=00002a27-0000-1000-8000-00805f9b34fb), READ handle=22 64 | * * System ID (uuid=00002a23-0000-1000-8000-00805f9b34fb), READ handle=24 65 | * * PnP ID (uuid=00002a50-0000-1000-8000-00805f9b34fb), READ handle=26 66 | * Body Composition (uuid=0000181b-0000-1000-8000-00805f9b34fb) 67 | * * Current Time (uuid=00002a2b-0000-1000-8000-00805f9b34fb), READ WRITE handle=29 68 | * * Body Composition Feature (uuid=00002a9b-0000-1000-8000-00805f9b34fb), READ handle=31 69 | * * Body Composition Measurement (uuid=00002a9c-0000-1000-8000-00805f9b34fb), INDICATE handle=33 70 | * * Body Composition History (uuid=00002a2f-0000-3512-2118-0009af100700), WRITE NOTIFY handle=36 71 | * Huami Configuration Service (uuid=00001530-0000-3512-2118-0009af100700) 72 | * * DFU Control point (uuid=00001531-0000-3512-2118-0009af100700), WRITE NOTIFY handle=40 73 | * * DFU Packet (uuid=00001532-0000-3512-2118-0009af100700), WRITE NO RESPONSE handle=43 74 | * * Peripheral Preferred Connection Parameters (uuid=00002a04-0000-1000-8000-00805f9b34fb), READ WRITE NOTIFY handle=45 75 | * * Scale configuration (uuid=00001542-0000-3512-2118-0009af100700), READ WRITE NOTIFY handle=48 76 | * * Battery (uuid=00001543-0000-3512-2118-0009af100700), READ WRITE NOTIFY handle=51 77 | 78 | ## Custom services/chars 79 | 80 | ### Body Composition Feature (00002a9b-0000-1000-8000-00805f9b34fb) 81 | Apparently not used 82 | 83 | ### Body Composition Measurement (00002a9c-0000-1000-8000-00805f9b34fb) 84 | It is used as notifications 85 | 86 | If notified, you'll receive the same weight data as in advertisements 87 | 88 | ### Body Measurement History (00002a2f-0000-3512-2118-0009af100700) 89 | This is the main characteristic, it gives the measurement history 90 | The device id is randomly chosen at first start of mi fit, the scale keep track of where each device is so it doesn't send all the data each time, and don't skip any data either 91 | 92 | #### Get data size 93 | 94 | Send 0x01 [device id] 95 | Si no response or response lenght is less than 3 or reponse[0] it not 1, send 0x03 96 | Data size = response[1] and response[2], send 0x03 to end 97 | 98 | #### Get data 99 | 100 | Register to notifications and send 0x02 101 | Get all notifications and send 0x03 at the end 102 | Each notifications should have the same data as the advertisements 103 | If you have as much data as indicated by the get data size command, send 0x04 [device id] to update your history position 104 | If registering to notifications or sending the 0x02 failed, send 0x03 anyway 105 | 106 | ### Scale configuration (00001542-0000-3512-2118-0009af100700) 107 | 108 | There's several commands there, but nothing really special. No idea what's the "one foot measure" but it seems useless. 109 | 110 | #### Set unit 111 | Send 0x06 0x04 0x00 [unit] where [unit] is 0x00 for SI, 0x01 for imperial and 0x02 for catty 112 | 113 | #### Enable Partial measures 114 | Send 0x06 0x10 0x00 [!enable] and you should receive a response that is 0x16 0x06 0x10 0x00 0x01 115 | 116 | #### Erase history record 117 | Send 0x06 0x12 0x00 0x00, you should receive a response that is 0x16 0x06 0x12 0x00 0x01 118 | 119 | #### Enable LED display 120 | Send 0x04 0x02 to enable, 0x04 0x03 to disable 121 | 122 | #### Calibrate 123 | Send 0x06 0x05 0x00 0x00 124 | 125 | #### Self test 126 | Send 0x04 0x01 to enable 0x04 0x04 to disable 127 | 128 | ### Set Sandglass Mode 129 | Send 0x06 [mode] 0x00 where mode is an uint16 that equals 0x000A or 0x000B 130 | 131 | ### Get Sandglass Mode 132 | Read and if mode is set, it is equal to 0x03 0x00 133 | 134 | #### Start One Foot Measure 135 | Register to notifications and send 0x06 0x0f 0x00 0x00 136 | You should get a notification like 0x06 0x0f 0x00 [flags] [time]\*2 137 | The only known flags are finished (0x02) and measuring (0x01) 138 | Time is inverted (time = (time[1] << 8) | time[0]) and multiplied by 100 139 | This feature seems pretty useless 140 | 141 | #### Stop One Foot Measure 142 | Send 0x06 0x11 0x00 0x00 143 | 144 | ### Date and time (00002a2b-0000-1000-8000-00805f9b34fb) 145 | You can read and write it, format: year[0], year[1], month, day, hour, min, sec, 0x00, 0x00 146 | 147 | ### Battery (00001543-0000-3512-2118-0009af100700) 148 | Two uint8, if both equals 0x01, then it's a low battery alert, simple as that. 149 | 150 | ## Advertisement 151 | The scale also works using advertisement packets, with a adType 0xff (OEM data) that is unknown yet, and a adType 0x16 (Service Data) that have this format: 152 | 153 | Data is 17 bytes long, with the first 4 bytes being an UUID, the other 13 bytes are the payload 154 | 155 | Payload format (year, impedance and weight are little endian): 156 | 157 | * bytes 0 and 1: control bytes 158 | * bytes 2 and 3: year 159 | * byte 4: month 160 | * byte 5: day 161 | * byte 6: hours 162 | * byte 7: minutes 163 | * byte 8: seconds 164 | * bytes 9 and 10: impedance 165 | * bytes 11 and 12: weight (`*100` for pounds and catty, `*200` for kilograms) 166 | 167 | Control bytes format (LSB first): 168 | 169 | * bit 0: unused 170 | * bit 1: unused 171 | * bit 2: unused 172 | * bit 3: unused 173 | * bit 4: unused 174 | * bit 5: partial data 175 | * bit 6: unused 176 | * bit 7: weight sent in pounds 177 | * bit 8: finished (is there any load on the scale) 178 | * bit 9: weight sent in catty 179 | * bit 10: weight stabilised 180 | * bit 11: unused 181 | * bit 12: unused 182 | * bit 13: unused 183 | * bit 14: impedance stabilized 184 | * bit 15: unused 185 | 186 | ## Thanks 187 | KailoKyra for his help, Hopper and Radare2/Cutter, shell-storm.org, gregstoll.com, openscale (and oliexdev for his knowledge), Wingjam (on github), and the poor souls who posted the JAR and some JNIs of holtek's SDK. 188 | --------------------------------------------------------------------------------