diff --git a/.gitignore b/.gitignore index 83658ec..0caef0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *.pyc *.log +heartrate_*.* +.env +env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/README.md b/README.md index b34b615..a104806 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # MiBand2 -Library to work with Xiaomi MiBand 2 +Library to work with Xiaomi MiBand 2 (Support python2/python3) +[Read the Article here](https://medium.com/@a.nikishaev/how-i-hacked-xiaomi-miband-2-to-control-it-from-linux-a5bd2f36d3ad) # Contributors & Info Sources 1) Base lib provided by [Leo Soares](https://github.com/leojrfs/miband2) 2) Additional debug & fixes was made by my friend [Volodymyr Shymanskyy](https://github.com/vshymanskyy/miband2-python-test) -3) Some info that really helped i got from (Freeyourgadget team)[https://github.com/Freeyourgadget/Gadgetbridge/tree/master/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband2] +3) Some info that really helped i got from [Freeyourgadget team](https://github.com/Freeyourgadget/Gadgetbridge/tree/master/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband2) + +## Online Course "Object Detection with PyTorch" +Subscribe to my new online course: [LearnML.Today](http://learnml.today/) # Run @@ -18,11 +22,56 @@ pip install -r requirements.txt ```sh sudo hcitool lescan ``` -5) Run example.py +5) Run this to auth device +```sh +python example.py --mac MAC_ADDRESS --init +``` +6) Run this to call demo functions ```sh -python example.py MAC_ADDRESS +python example.py --standard --mac MAC_ADDRESS +python example.py --help ``` -6) If you having problems(BLE can glitch sometimes) try this and repeat from 4) +7) If you having problems(BLE can glitch sometimes) try this and repeat from 4) ```sh sudo hciconfig hci0 reset ``` +Also there is cool JS library that made Volodymyr Shymansky https://github.com/vshymanskyy/miband-js + +# Raw PPG data + +You can record raw [PPG](https://en.wikipedia.org/wiki/Photoplethysmogram) data to a file: +```sh +python3 dump_ppg.py MAC_address ppg_data.csv +``` + +The resulting data can be plotted: +```sh +python3 plot_ppg.py -f ppg_data.csv +``` + +![Raw Data](/raw_ppg.png) + +![Raw Data Zoomed in](/raw_ppg_zoom.png) + +Or you can also view raw the PPG data in realtime: + +```sh +python3 plot_ppg.py -m MAC_address +``` + +# Donate +If you like what I'm doing, you can send me some money for pepsi(I dont drink alcohol). https://patreon.com/mlworld + +

+ + CC0 + +
+ To the extent possible under law, + + Andrey Nikishaev + has waived all copyright and related or neighboring rights to + Library to work with Xiaomi MiBand 2 . +

diff --git a/base.py b/base.py index 28c32d7..6868f5c 100644 --- a/base.py +++ b/base.py @@ -1,12 +1,16 @@ import struct import time import logging -from datetime import datetime +from datetime import datetime, timedelta from Crypto.Cipher import AES -from Queue import Queue, Empty -from bluepy.btle import Peripheral, DefaultDelegate, ADDR_TYPE_RANDOM +try: + from Queue import Queue, Empty +except ImportError: + from queue import Queue, Empty +from bluepy.btle import Peripheral, DefaultDelegate, ADDR_TYPE_RANDOM, BTLEException -from constants import UUIDS, AUTH_STATES, ALERT_TYPES, QUEUE_TYPES + +from constants import UUIDS, AUTH_STATES, ALERT_TYPES, QUEUE_TYPES, DATA_TYPES class AuthenticationDelegate(DefaultDelegate): @@ -19,14 +23,13 @@ def __init__(self, device): def handleNotification(self, hnd, data): # Debug purposes - # self.device._log.debug("DATA: " + str(data.encode("hex"))) - # self.device._log.debug("HNd" + str(hnd)) if hnd == self.device._char_auth.getHandle(): if data[:3] == b'\x10\x01\x01': self.device._req_rdn() elif data[:3] == b'\x10\x01\x04': self.device.state = AUTH_STATES.KEY_SENDING_FAILED elif data[:3] == b'\x10\x02\x01': + # 16 bytes random_nr = data[3:] self.device._send_enc_rdn(random_nr) elif data[:3] == b'\x10\x02\x04': @@ -39,10 +42,79 @@ def handleNotification(self, hnd, data): else: self.device.state = AUTH_STATES.AUTH_FAILED elif hnd == self.device._char_heart_measure.getHandle(): - rate = struct.unpack('bb', data)[1] - self.device.queue.put((QUEUE_TYPES.HEART, rate)) + self.device.queue.put((QUEUE_TYPES.HEART, data)) + elif hnd == 0x38: + # Not sure about this, need test + if len(data) == 20 and struct.unpack('b', data[0:1])[0] == 1: + self.device.queue.put((QUEUE_TYPES.RAW_ACCEL, data)) + elif len(data) == 16: + self.device.queue.put((QUEUE_TYPES.RAW_HEART, data)) + elif self.device.dataType == DATA_TYPES.PPG: + self.device.queue.put((QUEUE_TYPES.RAW_HEART, data)) + + # The fetch characteristic controls the communication with the activity characteristic. + # It can trigger the communication. + elif hnd == self.device._char_fetch.getHandle(): + if data[:3] == b'\x10\x01\x01': + # get timestamp from what date the data actually is received + year = struct.unpack(" datetime.now() - timedelta(minutes=1): + self.device.active = False + return + print("Trigger more communication") + time.sleep(1) + t = self.device.last_timestamp + timedelta(minutes=1) + self.device.start_get_previews_data(t) + else: + pkg = self.device.pkg + self.device.pkg += 1 + i = 1 + while i < len(data): + index = int(pkg) * 4 + (i - 1) / 4 + timestamp = self.device.first_timestamp + timedelta(minutes=index) + self.device.last_timestamp = timestamp + # category = int.from_bytes(data[i:i + 1], byteorder='little') + category = struct.unpack("= 2 else None - month = struct.unpack('b', bytes[2])[0] if len(bytes) >= 3 else None - day = struct.unpack('b', bytes[3])[0] if len(bytes) >= 4 else None - hours = struct.unpack('b', bytes[4])[0] if len(bytes) >= 5 else None - minutes = struct.unpack('b', bytes[5])[0] if len(bytes) >= 6 else None - seconds = struct.unpack('b', bytes[6])[0] if len(bytes) >= 7 else None + month = struct.unpack('b', bytes[2:3])[0] if len(bytes) >= 3 else None + day = struct.unpack('b', bytes[3:4])[0] if len(bytes) >= 4 else None + hours = struct.unpack('b', bytes[4:5])[0] if len(bytes) >= 5 else None + minutes = struct.unpack('b', bytes[5:6])[0] if len(bytes) >= 6 else None + seconds = struct.unpack('b', bytes[6:7])[0] if len(bytes) >= 7 else None + day_of_week = struct.unpack('b', bytes[7:8])[0] if len(bytes) >= 8 else None + fractions256 = struct.unpack('b', bytes[8:9])[0] if len(bytes) >= 9 else None + + return {"date": datetime(*(year, month, day, hours, minutes, seconds)), "day_of_week": day_of_week, "fractions256": fractions256} - return datetime(*(year, month, day, hours, minutes, seconds)) + @staticmethod + def create_date_data(date): + data = struct.pack( 'hbbbbbbbxx', date.year, date.month, date.day, date.hour, date.minute, date.second, date.weekday(), 0 ) + return data def _parse_battery_response(self, bytes): - level = struct.unpack('b', bytes[1])[0] if len(bytes) >= 2 else None - last_level = struct.unpack('b', bytes[19])[0] if len(bytes) >= 20 else None - status = 'normal' if struct.unpack('b', bytes[2])[0] == 0 else "charging" + level = struct.unpack('b', bytes[1:2])[0] if len(bytes) >= 2 else None + last_level = struct.unpack('b', bytes[19:20])[0] if len(bytes) >= 20 else None + status = 'normal' if struct.unpack('b', bytes[2:3])[0] == 0 else "charging" datetime_last_charge = self._parse_date(bytes[11:18]) datetime_last_off = self._parse_date(bytes[3:10]) @@ -149,19 +273,38 @@ def _parse_battery_response(self, bytes): "status": status, "level": level, "last_level": last_level, - "last_level": last_level, "last_charge": datetime_last_charge, "last_off": datetime_last_off } return res - def _init_after_auth(self): - # char = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_LE_PARAMS)[0] - # print char.read() - # char.write(b"\x03\x01", True) - return - # desc = self._char_heart_measure.getDescriptors(forUUID=UUIDS.DESCRIPTOR_AUTH)[0] - # desc.write(b"\x01\x01", True) + # Queue ################################################################### + + def _get_from_queue(self, _type): + try: + res = self.queue.get(False) + except Empty: + return None + if res[0] != _type: + self.queue.put(res) + return None + return res[1] + + def _parse_queue(self): + while True: + try: + res = self.queue.get(False) + _type = res[0] + if self.heart_measure_callback and _type == QUEUE_TYPES.HEART: + self.heart_measure_callback(struct.unpack('bb', res[1])[1]) + elif self.heart_raw_callback and _type == QUEUE_TYPES.RAW_HEART: + self.heart_raw_callback(self._parse_raw_heart(res[1])) + elif self.accel_raw_callback and _type == QUEUE_TYPES.RAW_ACCEL: + self.accel_raw_callback(self._parse_raw_accel(res[1])) + except Empty: + break + + # API #################################################################### def initialize(self): self.setDelegate(AuthenticationDelegate(self)) @@ -171,7 +314,10 @@ def initialize(self): self.waitForNotifications(0.1) if self.state == AUTH_STATES.AUTH_OK: self._log.info('Initialized') + self._auth_notif(False) return True + elif self.state is None: + continue self._log.error(self.state) return False @@ -184,8 +330,9 @@ def authenticate(self): self.waitForNotifications(0.1) if self.state == AUTH_STATES.AUTH_OK: self._log.info('Authenticated') - self._init_after_auth() return True + elif self.state is None: + continue self._log.error(self.state) return False @@ -196,21 +343,75 @@ def get_battery_info(self): def get_current_time(self): char = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_CURRENT_TIME)[0] - return self._parse_date(char.read()[0:7]) + return self._parse_date(char.read()[0:9]) + + def set_current_time(self, date): + char = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_CURRENT_TIME)[0] + return char.write(self.create_date_data(date), True) + + def get_revision(self): + svc = self.getServiceByUUID(UUIDS.SERVICE_DEVICE_INFO) + char = svc.getCharacteristics(UUIDS.CHARACTERISTIC_REVISION)[0] + data = char.read() + revision = struct.unpack('9s', data[-9:])[0] if len(data) == 9 else None + return revision + + def get_hrdw_revision(self): + svc = self.getServiceByUUID(UUIDS.SERVICE_DEVICE_INFO) + char = svc.getCharacteristics(UUIDS.CHARACTERISTIC_HRDW_REVISION)[0] + data = char.read() + revision = struct.unpack('8s', data[-8:])[0] if len(data) == 8 else None + return revision + + def set_encoding(self, encoding="en_US"): + char = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_CONFIGURATION)[0] + packet = struct.pack('5s', encoding) + packet = b'\x06\x17\x00' + packet + return char.write(packet) + + def set_heart_monitor_sleep_support(self, enabled=True, measure_minute_interval=1): + char_m = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_MEASURE)[0] + char_d = char_m.getDescriptors(forUUID=UUIDS.NOTIFICATION_DESCRIPTOR)[0] + char_d.write(b'\x01\x00', True) + self._char_heart_ctrl.write(b'\x15\x00\x00', True) + # measure interval set to off + self._char_heart_ctrl.write(b'\x14\x00', True) + if enabled: + if measure_minute_interval > 120: + measure_minute_interval = 120 + self._char_heart_ctrl.write(b'\x15\x00\x01', True) + # measure interval set + self._char_heart_ctrl.write(b'\x14' + bytes([measure_minute_interval]), True) + char_d.write(b'\x00\x00', True) + + def set_heart_monitor_measurement_interval(self, enabled=True, measure_minute_interval=1): + if enabled: + if measure_minute_interval > 120: + measure_minute_interval = 120 + self._char_heart_ctrl.write(b'\x14' + bytes([measure_minute_interval]), True) + else: + self._char_heart_ctrl.write(b'\x14\x00', True) + + def get_serial(self): + svc = self.getServiceByUUID(UUIDS.SERVICE_DEVICE_INFO) + char = svc.getCharacteristics(UUIDS.CHARACTERISTIC_SERIAL)[0] + data = char.read() + serial = struct.unpack('12s', data[-12:])[0] if len(data) == 12 else None + return serial def get_steps(self): char = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_STEPS)[0] a = char.read() steps = struct.unpack('h', a[1:3])[0] if len(a) >= 3 else None meters = struct.unpack('h', a[5:7])[0] if len(a) >= 7 else None - fat_gramms = struct.unpack('h', a[2:4])[0] if len(a) >= 4 else None + fat_grams = struct.unpack('h', a[2:4])[0] if len(a) >= 4 else None # why only 1 byte?? - callories = struct.unpack('b', a[9])[0] if len(a) >= 10 else None + calories = struct.unpack('b', a[9:10])[0] if len(a) >= 10 else None return { "steps": steps, "meters": meters, - "fat_gramms": fat_gramms, - "callories": callories + "fat_grams": fat_grams, + "calories": calories } @@ -228,32 +429,182 @@ def get_heart_rate_one_time(self): self._char_heart_ctrl.write(b'\x15\x02\x01', True) res = None while not res: - band.waitForNotifications(self.timeout) + self.waitForNotifications(self.timeout) res = self._get_from_queue(QUEUE_TYPES.HEART) - rate = res + rate = struct.unpack('bb', res)[1] return rate - def get_heart_rate_realtime(self, callback, timeout=10): - """ - I though that mechanics of this request is not a realtime heart rate measure, - but instead iterative measure of N times with sending reasults one by one. - Thus we just make loop in which we sending iterative heart measure request after each 15sec, and that - seems to work. - """ - # stop continous - self._char_heart_ctrl.write(b'\x15\x01\x00', True) - # stop manual - self._char_heart_ctrl.write(b'\x15\x02\x00', True) - - timeout = time.time() + timeout - while timeout > time.time(): - timeout_base = time.time() + 15 - # start continous - self._char_heart_ctrl.write(b'\x15\x01\x01', True) - while timeout_base > time.time(): - self.waitForNotifications(self.timeout) - res = self._get_from_queue(QUEUE_TYPES.HEART) - if res: - callback(res) + def start_heart_rate_realtime(self, heart_measure_callback): + char_m = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_MEASURE)[0] + char_d = char_m.getDescriptors(forUUID=UUIDS.NOTIFICATION_DESCRIPTOR)[0] + char_ctrl = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_CONTROL)[0] + + self.heart_measure_callback = heart_measure_callback + + # stop heart monitor continues & manual + char_ctrl.write(b'\x15\x02\x00', True) + char_ctrl.write(b'\x15\x01\x00', True) + # enable heart monitor notifications + char_d.write(b'\x01\x00', True) + # start hear monitor continues + char_ctrl.write(b'\x15\x01\x01', True) + t = time.time() + while True: + self.waitForNotifications(0.5) + self._parse_queue() + # send ping request every 12 sec + if (time.time() - t) >= 12: + char_ctrl.write(b'\x16', True) + t = time.time() + + def start_raw_data_realtime(self, heart_measure_callback=None, heart_raw_callback=None, accel_raw_callback=None): + char_m = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_MEASURE)[0] + char_d = char_m.getDescriptors(forUUID=UUIDS.NOTIFICATION_DESCRIPTOR)[0] + char_ctrl = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_CONTROL)[0] + + if heart_measure_callback: + self.heart_measure_callback = heart_measure_callback + if heart_raw_callback: + self.heart_raw_callback = heart_raw_callback + if accel_raw_callback: + self.accel_raw_callback = accel_raw_callback + + char_sensor = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_SENSOR)[0] + # char_sens_d = char_sensor1.getDescriptors(forUUID=UUIDS.NOTIFICATION_DESCRIPTOR)[0] + + # char_sensor2 = self.svc_1.getCharacteristics('000000010000351221180009af100700')[0] + # char_sens_d2 = char_sensor2.getDescriptors(forUUID=UUIDS.NOTIFICATION_DESCRIPTOR)[0] + + # char_sensor3 = self.svc_1.getCharacteristics('000000070000351221180009af100700')[0] + # char_sens_d3 = char_sensor3.getDescriptors(forUUID=UUIDS.NOTIFICATION_DESCRIPTOR)[0] + + # char_sens_d1.write(b'\x01\x00', True) + # char_sens_d2.write(b'\x01\x00', True) + # char_sensor2.write(b'\x01\x03\x19') + # char_sens_d2.write(b'\x00\x00', True) + # char_d.write(b'\x01\x00', True) + # char_ctrl.write(b'\x15\x01\x01', True) + # char_sensor2.write(b'\x02') + + # stop heart monitor continues & manual + char_ctrl.write(b'\x15\x02\x00', True) + char_ctrl.write(b'\x15\x01\x00', True) + # WTF + # char_sens_d1.write(b'\x01\x00', True) + # enabling accelerometer & heart monitor raw data notifications + char_sensor.write(b'\x01\x03\x19') + # IMO: enablee heart monitor notifications + char_d.write(b'\x01\x00', True) + # start hear monitor continues + char_ctrl.write(b'\x15\x01\x01', True) + # WTF + char_sensor.write(b'\x02') + t = time.time() + while True: + self.waitForNotifications(0.5) + self._parse_queue() + # send ping request every 12 sec + if (time.time() - t) >= 12: + char_ctrl.write(b'\x16', True) + t = time.time() + + def writeHandle(self, handle, data, reply=False): + logging.debug("writeHandle %x %s", handle, data) + self.writeCharacteristic(handle, data, reply) + + def start_ppg_data_realtime(self, sample_duration_seconds=30, heart_measure_callback=None, heart_raw_callback=None, accel_raw_callback=None): + if heart_measure_callback: + self.heart_measure_callback = heart_measure_callback + if heart_raw_callback: + self.heart_raw_callback = heart_raw_callback + if accel_raw_callback: + self.accel_raw_callback = accel_raw_callback + + logging.debug("start_ppg_data_realtime") + self.dataType = DATA_TYPES.PPG + + char_sensor = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_SENSOR)[0] + char_ctrl = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_CONTROL)[0] + + self.writeHandle(0x36, b'\x01\x00', True) # 36 01 00 + + self._log.debug("Enable PPG raw data") + char_sensor.write(b'\x01\x02\x19') # 35 01 02 19 + + self._log.debug("Stop heart continuous") + char_ctrl.write(b'\x15\x01\x00', True) # 2C 15 01 00 + + self._log.debug("Start heart continuous") + char_ctrl.write(b'\x15\x01\x01', True) # 2C 15 01 01 + + self._log.debug("Start sensor data") + char_sensor.write(b'\x02') # 35 02 + + now_time = datetime.now() + ping_time = now_time + timedelta(seconds=10) + stop_time = now_time + timedelta(seconds=sample_duration_seconds) + while now_time < stop_time: + self.waitForNotifications(0.5) + self._parse_queue() + if now_time > ping_time: + logging.debug("Writing ping") + char_ctrl.write(b'\x16', True) + ping_time += timedelta(seconds=10) + now_time = datetime.now() + + self.stop_ppg_data_realtime() + + + def stop_ppg_data_realtime(self): + self._log.debug("stop_ppg_data_realtime") + + char_sensor = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_SENSOR)[0] + char_ctrl = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_CONTROL)[0] + + self._log.debug("Stop sensor data") + char_sensor.write(b'\x03') + + self._log.debug("Stop heart continuous") + char_ctrl.write(b'\x15\x01\x00', True) # 2C 15 01 00 + + self.dataType = DATA_TYPES.NONE + + def stop_realtime(self): + char_m = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_MEASURE)[0] + char_d = char_m.getDescriptors(forUUID=UUIDS.NOTIFICATION_DESCRIPTOR)[0] + char_ctrl = self.svc_heart.getCharacteristics(UUIDS.CHARACTERISTIC_HEART_RATE_CONTROL)[0] + + char_sensor1 = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_HZ)[0] + char_sens_d1 = char_sensor1.getDescriptors(forUUID=UUIDS.NOTIFICATION_DESCRIPTOR)[0] + + char_sensor2 = self.svc_1.getCharacteristics(UUIDS.CHARACTERISTIC_SENSOR)[0] + + # stop heart monitor continues + char_ctrl.write(b'\x15\x01\x00', True) + char_ctrl.write(b'\x15\x01\x00', True) + # IMO: stop heart monitor notifications + char_d.write(b'\x00\x00', True) + # WTF + char_sensor2.write(b'\x03') + # IMO: stop notifications from sensors + char_sens_d1.write(b'\x00\x00', True) + + self.heart_measure_callback = None + self.heart_raw_callback = None + self.accel_raw_callback = None + + def start_get_previews_data(self, start_timestamp): + self._auth_previews_data_notif(True) + self.waitForNotifications(0.1) + print("Trigger activity communication") + year = struct.pack("= buffer_len: + data = "%s, %s\n" % (int(time.time()), buffer[:buffer_len]) + fp.write(data) + buffer = buffer[buffer_len:] + else: + data = "%s, %s\n" % (int(time.time()), buffer) + fp.write(data) + + +def log(raw_ppg_array): + print(raw_ppg_array) + writedata(raw_ppg_array) + + +try: + band = MiBand2(MAC, debug=True) + band.setSecurityLevel(level="medium") + band.authenticate() + band.start_ppg_data_realtime( + sample_duration_seconds=60, heart_raw_callback=log, heart_measure_callback=heart) + band.disconnect() + writedata() +except BTLEException: + pass diff --git a/example.py b/example.py new file mode 100644 index 0000000..612100c --- /dev/null +++ b/example.py @@ -0,0 +1,81 @@ +import sys +import time +import argparse +from datetime import datetime +from base import MiBand2 +from constants import ALERT_TYPES + +parser = argparse.ArgumentParser() +parser.add_argument('-s', '--standard', action='store_true',help='Shows device information') +parser.add_argument('-r', '--recorded', action='store_true',help='Shows previews recorded data') +parser.add_argument('-l', '--live', action='store_true',help='Measures live heart rate') +parser.add_argument('-i', '--init', action='store_true',help='Initializes the device') +parser.add_argument('-m', '--mac', required=True, help='Mac address of the device') +parser.add_argument('-t', '--set_current_time', action='store_true',help='Set time') +args = parser.parse_args() + +MAC = args.mac # sys.argv[1] + +band = MiBand2(MAC, debug=True) +band.setSecurityLevel(level="medium") + +if args.init: + if band.initialize(): + print("Init OK") + band.set_heart_monitor_sleep_support(enabled=False) + band.disconnect() + sys.exit(0) +else: + band.authenticate() + +if args.recorded: + print('Print previews recorded data') + band._auth_previews_data_notif(True) + start_time = datetime.strptime("12.03.2018 01:01", "%d.%m.%Y %H:%M") + band.start_get_previews_data(start_time) + while band.active: + band.waitForNotifications(0.1) + +if args.standard: + print ('Message notif') + band.send_alert(ALERT_TYPES.MESSAGE) + time.sleep(3) + # this will vibrate till not off + print ('Phone notif') + band.send_alert(ALERT_TYPES.PHONE) + time.sleep(8) + print ('OFF') + band.send_alert(ALERT_TYPES.NONE) + print ('Soft revision:',band.get_revision()) + print ('Hardware revision:',band.get_hrdw_revision()) + print ('Serial:',band.get_serial()) + print ('Battery:', band.get_battery_info()) + print ('Time:', band.get_current_time()) + print ('Steps:', band.get_steps()) + print ('Heart rate oneshot:', band.get_heart_rate_one_time()) + +if args.set_current_time: + now = datetime.now() + print ('Set time to:', now) + print ('Returned: ', band.set_current_time(now)) + print ('Time:', band.get_current_time()) + +def l(x): + print ('Realtime heart:', x) + + +def b(x): + print ('Raw heart:', x) + + +def f(x): + print ('Raw accel heart:', x) + +if args.live: + # band.start_heart_rate_realtime(heart_measure_callback=l) + band.start_raw_data_realtime( + heart_measure_callback=l, + heart_raw_callback=b, + accel_raw_callback=f) + +band.disconnect() diff --git a/exmaple.py b/exmaple.py deleted file mode 100644 index 7680218..0000000 --- a/exmaple.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -import time -from base import MiBand2 -from constants import ALERT_TYPES - -MAC = sys.argv[1] - -band = MiBand2(MAC, debug=True) -band.setSecurityLevel(level="medium") - -if len(sys.argv) > 1: - band.initialize() - band.disconnect() - sys.exit(0) -else: - band.authenticate() - -print 'Message notif' -band.send_alert(ALERT_TYPES.MESSAGE) -time.sleep(3) -# this will vibrate till not off -print 'Phone notif' -band.send_alert(ALERT_TYPES.PHONE) -time.sleep(8) -print 'OFF' -band.send_alert(ALERT_TYPES.NONE) -print 'Battery:', band.get_battery_info() -print 'Time:', band.get_current_time() -print 'Steps:', band.get_steps() -print 'Heart rate oneshot:', band.get_heart_rate_one_time() - -def l(x): - print 'Realtime heart:', x -band.get_heart_rate_realtime(l, 60) - -band.disconnect() diff --git a/plot.py b/plot.py new file mode 100644 index 0000000..d29941d --- /dev/null +++ b/plot.py @@ -0,0 +1,17 @@ +import numpy as np +import pandas as pd +import sys +from stockstats import StockDataFrame +import matplotlib.pyplot as plt + +df = pd.DataFrame.from_csv(sys.argv[1], index_col=None) +print (df.head()) +df['time'] = pd.to_datetime(df['time'], unit='s') +df = df.set_index('time') +print (df.describe()) +# plt.subplot('111') +# df.plot(kind='line') +# plt.subplot('122') +# df.plot(kind='histogram') +df.rolling('120s').mean().plot() +plt.show() diff --git a/plot_ppg.py b/plot_ppg.py new file mode 100644 index 0000000..0e0bfc2 --- /dev/null +++ b/plot_ppg.py @@ -0,0 +1,157 @@ +import argparse +import logging +import matplotlib.pyplot as plt +import matplotlib.axes as plt_axes +import numpy as np +import time + +from base import MiBand2 +from bluepy.btle import BTLEException + +parser = argparse.ArgumentParser() +parser.add_argument( + '-m', '--mac', help='MAC address of the device', default="None") +parser.add_argument('-f', '--file', help='CSV data file containing data') +parser.add_argument( + '-d', '--debug', help='MAC address of the device', default=0) +args = parser.parse_args() + +if args.file and args.mac != "None": + print("Only one of -f/--file or -m/--mac can be specified!") + exit(1) + +if not args.file and args.mac == "None": + print("One of -f/--file or -m/--mac must be specified!") + exit(3) + +FORMAT = '%(asctime)s.%(msecs)03d-%(levelname)s: %(message)s' +if args.debug == 0: + logging.basicConfig( + format=FORMAT, datefmt='%H:%M:%S', level=logging.INFO) +else: + logging.basicConfig( + format=FORMAT, datefmt='%H:%M:%S', level=logging.DEBUG) + + +def plot_file_type1(file_name): + all_data = [] + time_start = 0 + time_end = time_start + with open(file_name, 'r') as data_file: + header = data_file.readline() + data = data_file.readlines() + for raw_row in data: + # Input data is of the form: time, [csv array] + # Extract the time + first_comma = raw_row.index(',') + time = raw_row[:first_comma] + if time_start == 0: + time_start = int(time) + else: + time_end = int(time) + # Extract the csv_array + # Strip spaces, and replace the + row_with_brackets = raw_row[first_comma+1:].strip() + array_string = row_with_brackets.replace( + '[', '').replace(']', '').replace(' ', '') + logging.debug("%s | %s", time, array_string) + array_int = [int(i) for i in array_string.split(',')] + all_data += array_int + + times = np.array(range(len(all_data))) + duration = time_end - time_start + logging.debug("Data spans %d seconds", duration) + timesd = times/(len(all_data)*1.0) + timesd *= duration + + plot_all_data(timesd, all_data) + + +def plot_all_data(xdata, ydata): + plt.plot(xdata, ydata) + plt.title('Raw PPG data') + plt.xlabel('Time (s)') + plt.ylabel('Intensity (arb.)') + plt.show() + + +def start_plot(): + global ax + fig = plt.figure() + ax = fig.add_subplot(111) + + hl, = plt.plot([], []) + plt.title('Raw PPG data') + plt.xlabel('Time (s)') + plt.ylabel('Intensity (arb.)') + return hl + + +def update_line(ax, hl, new_xdata, new_ydata): + hl.set_xdata(np.append(hl.get_xdata(), new_xdata)) + hl.set_ydata(np.append(hl.get_ydata(), new_ydata)) + + ax.relim() + ax.autoscale_view() + + plt.draw() + plt.pause(0.0001) + + +def plot_data(time_offset, delta_t, data): + global ax, plot + new_times = np.array(range(len(data))) + new_times = new_times/(len(data)*1.0) + new_times *= delta_t*len(data) + new_times += time_offset + + logging.debug("dt: %s, Offset: %s", delta_t, time_offset) + update_line(ax, plot, new_times, data) + + +def raw_data(data): + global num_data_points, start_time, last_time, old_data + + now = time.time() + # Manually calculated via timed calibration run (~1225 data points over ~50 seconds) + dt = 0.04 + + if num_data_points == 0: + num_data_points = len(data) + old_data = data + last_time = now + return + + if old_data is not None: + start_time = now - dt*num_data_points + plot_data(0, dt, old_data) + old_data = None + + time_offset = num_data_points * dt + plot_data(time_offset, dt, data) + + num_data_points += len(data) + last_time = now + + +def plot_live(MAC): + try: + band = MiBand2(MAC, debug=True) + band.setSecurityLevel(level="medium") + band.authenticate() + band.start_ppg_data_realtime( + sample_duration_seconds=60, heart_raw_callback=raw_data) + band.disconnect() + plt.show() + except BTLEException: + pass + + +if args.file: + print("Plotting data from file: %s " % args.file) + plot_file_type1(args.file) +elif args.mac != "None": + print("Plotting live data from device: %s " % args.mac) + num_data_points = 0 + plot = start_plot() + plot_live(args.mac) diff --git a/raw_ppg.png b/raw_ppg.png new file mode 100644 index 0000000..0cb8d0a Binary files /dev/null and b/raw_ppg.png differ diff --git a/raw_ppg_zoom.png b/raw_ppg_zoom.png new file mode 100644 index 0000000..0a7a4af Binary files /dev/null and b/raw_ppg_zoom.png differ diff --git a/requirements.txt b/requirements.txt index 1bc9be0..0fd7f22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -bluepy -pycrypto \ No newline at end of file +bluepy==1.3.0 +pycrypto==2.6.1