diff --git a/README.asciidoc b/README.asciidoc index 75d48fd..473a051 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -10,6 +10,8 @@ Hardware access - http://openyou.org If you find libfitbit useful, please donate to the project at http://pledgie.com/campaigns/14375 +Join the libfitbit group on fitbit.com: http://www.fitbit.com/group/227FRX + == Credits and Thanks == Thanks to Matt Cutts for hooking me up with the hardware - diff --git a/doc/fitbit_data.rst b/doc/fitbit_data.rst new file mode 100644 index 0000000..047469c --- /dev/null +++ b/doc/fitbit_data.rst @@ -0,0 +1,368 @@ + +==================== + FitBit Data Format +==================== + +:author: BenoƮt Allard +:date: July 4th, 2012 + +Introduction +============ + +This document is part of the `libfitbit project`_. + +This document aims at describing the data flow between the *fitbit +service* and the *tracker* itself. We will try to abstract as much as +possible the underlying protocol, and focus on the structure of the +data, and the way it is transfered. + +Data from this document has been gathered through reverse in depth +analyse of logs of communication between the *tracker* and the +*service*. See `method of operation`_. + +The motivation behind this analyse is at first the intelectual process +of deciphering data, but as well the will of having full control about +a tracker I wear day and night, and by such, data about my lifestyle. + +Device Description +================== + +The *fitbit tracker* comes with a USB base and a software to be +installed on the host computer. The software will run in *daemon mode* +and request periodically the data from the *tracker*. This is done +through the air, and of course, only if the tracer is near to the +base. The software on the computer is not *tracker* dependent, and +every *tracker* can use any base to synchronise its data with the +fitbit service. + +Communication +============= + +A dialog between the software and the fitbit service always starts +with a web-request from the software containing the identifier of the +*tracker* found at the moment in the proximity of the base. The fitbit +service then answer with a serie of commands to be sent to the +*tracker*. Once those commands are executed on the *tracker*, the +software sends the raw answers to the fitbit service. This one answers +again with a serie of commands, which are to be executed on the +*tracker*. This go on until the fitbit service has nothing to ask +anymore. This are four round trips most of the time. The software then +put the *tracker* in sleep mode, indicating that he is not interested +in its data for the next 15 minutes. + +Method of operation +=================== + +The ``fitbit_client.py`` script of the original `libfitbit project`_ +has been modified to record on disc every transfered bits from and to +the *tracker*. This allow statistical analysis of days of data +transfer. + +Communication with the tracker +============================== + +The communication with the *tracker* is done through the *base* using +the `ANT protocol`_. + +The tracker receives one *opcode* at a time, optionally followed with +*payload*, and sends its *response*. One request from the *fitbit +service* contains from zero to eight requests for the *tracker*. Those +are read, write, erase and a few others yet to be enciphered. + +Opcodes +======= + +An opcode is **always** seven bytes long. most of the time, only the +first few bytes are not zero. + +The memory read is not the same as the memory written, even if the +*index* can be the same. + +Get Device Information +---------------------- + +:opcode: [0x24, 0 (6 times)] +:response: [serial, hardrev, bslmaj, bslmin, appmaj, appmin, bslon, + onbase] + +The response first contains the serial number of the tracker on five +bytes, then the hardware revision, the BSL major version and minor +version, the App major version and minor version, if the BSL mode is +ON, and if the tracker is plugged on the base. Except the serial +number, every other information is coded on one byte. + +Read memory +----------- + +:opcode: [0x22, index, 0 (5 times)] +:response: data + +Where *index* is the index of the memory bank requested. + +The response is the content of the memory and its meaning differs from +memory to memory. + +Write memory +------------ + +:opcode: [0x23, index, datalen, 0 (4 times) ] +:payload: data +:response: [0x41, 0 (6 times)] + +Where *index* is the index of the memory to be written, and *datalen* the +length of the payload. + +The content of the payload is index dependant. + +Erase memory +------------ + +:opcode: [0x25, index, timestamp, 0] +:response: [0x41, 0 (6 times)] + +Where *index* is the index of the memory bank to be erased, and +*timestamp* (on four bytes, MSB) is the date until which the data +should be erased. + +Read Memory banks +================= + +.. _bank0r: + +bank0 +----- + +This bank contains records about **steps** and **score**. + +Data format +........... + +The records are three bytes long. The first always has its MSB +set. The second byte gives the active score, and the third the steps. + +.. note:: Because the timestamp ``0x80000000`` correspond to the 19th + of January 2038, we might expect the fitbit team to change + their data format before this date. Until this date, every + timestamps will not have their MSB set, and the distinction + between record and timestamps themselves will be easy. + +Example +....... + +.. _bank1r: + +bank1 +----- + +This bank is about the **daily statistics**. However, a new record +with the current data is appended each time the bank is being read, or +the clock switch to another day (midnight). + +Data format +........... + +Each record is 16 bytes long and starts with a timestamp on four +bytes. Follows then XX, steps, distance and 10 x floors. Both steps +and distance are stored on four bytes, the first one (probably +something with calories) and the floors are on two bytes. The unit +used for the distance is the centimeter. + +.. note:: On the first version of the fitbit tracker (**not** Ultra), + the records are 14 bytes long as they miss the last two + bytes about the amount of floors climbed. + +.. note:: The midnight records, are registered with the date of the + next day. A record for ``2012-07-07 00:00:00`` is actually + about the 6th of July. + +Example: +........ + +:: + + 60 A0 05 50 9C 41 53 19 00 00 31 E5 49 00 1E 00 + +Which can be interpreted as follow: + +- ``0x5005a060``: 2012-07-17 19:26:56 +- ``0x419c``: 16796 : approximately 1845 calories (*.1103 - 7) +- ``0x00001953``: 6483 steps +- ``0x0049e531``: 4.842801km +- ``0x001E``: 3 floors + +bank2 +----- + +This bank is about **recorded activities**. + +Data format +........... + +Records are 15 bytes long, they are prefixed with a timestamp on four bytes. + +The record can be categorized in multiple kinds. Their kind is decided +by the value of the byte 6. + +Value of 1: + + This is a stop of the recorded activity. the record will contain + information about the length of the activity, the steps, the + floors, and more. + +Value of 2: + + This one seems to always go in pair with the value of 3. + +Value of 3: + + This one seems to always go in pair with the value of 2, A record + with a value of 2 usually follow two seconds after the record with + a value of 3. + +Value of 4: + + Not found yet + +Value of 5: + + This means a start of the activity if all fields are set to 0, + else, the meaning is still to be discovered. + +Example +....... + + + +bank3 +----- + +This bank contains data, but a request to read it is never sent from +the *fitbit service*. + +Data Format +........... + +This bank always contains thirty bytes. The meaning of only the first +ones is known. + +The first five bytes contains the serial number, followed by the +hardware revision. + +Example +....... + +:: + + 01 02 03 04 05 0C 08 10 08 01 08 00 00 FF D8 00 06 A9 1D 9E 43 6A 3A + 63 48 83 BA 6E 1D 64 + +Which can be decoded as follow:: + + Serial: 0102030405 + Hardware revision: 12 + +bank4 +----- + +This bank is the same as `bank0w`_. + +bank5 +----- + +* This bank is one of the few without timestamps +* This bank is not related to the amount of steps. +* An erase has an effect on the data, however, the values don't go + to 0. +* This bank is always 14 bytes long. + + +bank6 +----- + +This bank contains data about **floors climbed**. + +Data format +........... + +This information is transfered on two bytes, the first byte having its +MSB set. There is one record per minute, and the records are prefixed +by a timestamp on four bytes in LSB format (see also `bank0r`_). In +case where more than one minute separates two floor climbing record, +instead of an empty record, a new timestamp will be inserted before +the next climbing record. + +The number of floors climbed during the recorded minute is equal to +the value of the second byte divided by ten. + +Example: +........ + +:: + + 4F F0 4A 4B 80 0A 4F F0 4A FF 80 0A 80 14 4F F0 4B EF 80 14 80 14 4F + F0 4C DF 80 14 + +First we have a timestamp 0x4ff04a4b, then a record 0x800a, then a +timestamp 0x4ff04aff, then two records 0x800a and 0x8014, a timestamp +again 0x4ff04bef, two records 0x8014 and 0x8014, a timestamp 4ff04cdf +and one record 0x8014. + +This can be decoded as follow:: + + Time: 2012-07-01 15:02:03: 1 Floors + Time: 2012-07-01 15:05:03: 1 Floors + Time: 2012-07-01 15:06:03: 2 Floors + Time: 2012-07-01 15:09:03: 2 Floors + Time: 2012-07-01 15:10:03: 2 Floors + Time: 2012-07-01 15:13:03: 2 Floors + +bank7 +----- + +This bank is never requested from the *fitbit service*. + +Its content is empty. + +Write memory banks +================== + +.. _bank0w: + +bank0 +----- + +This bank always receives 64 bytes. Those bytes are about the device +settings as set on the web page *Device Settings* and *Profile +Settings*. + +Data format +........... + +The following information is to prefix with a big **it looks +like... ** as those are only estimation based on different seen +values. + +* First four bytes are always zero +* Then are some bytes about yourself: + - length + - preferred unit + - stride length + - gender + - time zone +* what should be displayed on the tracker + - greeting on/off + - left hand / right hand + - clock format +* 7 zeros +* options displayed on tracker +* the greeting name (10 bytes), right padded with 2 zeros +* the three chatter texts (3 x 10 bytes). each right padded with 2 + zeros + +bank1 +----- + +This bank always receive 16 bytes. + +.. _`libfitbit project`: https://github.com/qdot/libfitbit +.. _`ANT protocol`: something here diff --git a/doc/fitbit_protocol.asciidoc b/doc/fitbit_protocol.asciidoc index 8d629c2..7edfca3 100644 --- a/doc/fitbit_protocol.asciidoc +++ b/doc/fitbit_protocol.asciidoc @@ -199,7 +199,7 @@ listed below): - Client contacts website at /device/tracker/uploadData, sends basic client and platform information - Website replies with opcode for tracker data request -- Client gets tracker data (serial number, firmware version, etc...), +- Client gets tracker data (serial number, hardware revision, etc...), sends base response. Sends to /device/tracker/dumpData/lookupTracker - Website replies with website tracker and user ids based on tracker serial number, and opcodes for data dumping @@ -389,7 +389,7 @@ the tracker for information about itself. Taking the last two Device Data Portions: * Bytes 0-4 (G) - 5 byte Device Serial Number -* Byte 5 (H) - Firmware Version +* Byte 5 (H) - Hardware revision * Byte 6 (I) - BSL Major Version * Byte 7 (J) - BSL Minor Version (i.e. II.JJ = bsl version) * Byte 8 (K) - App Major Version diff --git a/python/antprotocol/bases.py b/python/antprotocol/bases.py deleted file mode 100644 index adf3be3..0000000 --- a/python/antprotocol/bases.py +++ /dev/null @@ -1,55 +0,0 @@ -from .protocol import ANTReceiveException -from .libusb import ANTlibusb -import usb - -class DynastreamANT(ANTlibusb): - """Class that represents the Dynastream USB stick base, for - garmin/suunto equipment. Only needs to set VID/PID. - - """ - VID = 0x0fcf - PID = 0x1008 - NAME = "Dynastream" - -class FitBitANT(ANTlibusb): - """Class that represents the fitbit base. Due to the extra - hardware to handle tracker connection and charging, has an extra - initialization sequence. - - """ - - VID = 0x10c4 - PID = 0x84c4 - NAME = "FitBit" - - def open(self, vid = None, pid = None): - if not super(FitBitANT, self).open(vid, pid): - return False - self.init() - return True - - def init(self): - # Device setup - # bmRequestType, bmRequest, wValue, wIndex, data - self._connection.ctrl_transfer(0x40, 0x00, 0xFFFF, 0x0, []) - self._connection.ctrl_transfer(0x40, 0x01, 0x2000, 0x0, []) - # At this point, we get a 4096 buffer, then start all over - # again? Apparently doesn't require an explicit receive - self._connection.ctrl_transfer(0x40, 0x00, 0x0, 0x0, []) - self._connection.ctrl_transfer(0x40, 0x00, 0xFFFF, 0x0, []) - self._connection.ctrl_transfer(0x40, 0x01, 0x2000, 0x0, []) - self._connection.ctrl_transfer(0x40, 0x01, 0x4A, 0x0, []) - # Receive 1 byte, should be 0x2 - self._connection.ctrl_transfer(0xC0, 0xFF, 0x370B, 0x0, 1) - self._connection.ctrl_transfer(0x40, 0x03, 0x800, 0x0, []) - self._connection.ctrl_transfer(0x40, 0x13, 0x0, 0x0, \ - [0x08, 0x00, 0x00, 0x00, - 0x40, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00 - ]) - self._connection.ctrl_transfer(0x40, 0x12, 0x0C, 0x0, []) - try: - self._receive() - except usb.USBError: - pass diff --git a/python/antprotocol/connection.py b/python/antprotocol/connection.py new file mode 100644 index 0000000..b4a64d2 --- /dev/null +++ b/python/antprotocol/connection.py @@ -0,0 +1,128 @@ +import usb, os, sys + +class ANTConnection(object): + """ An abstract class that represents a connection """ + + def open(self): + """ Open the connection """ + raise NotImplementedError() + + def close(self): + """ Close the connection """ + raise NotImplementedError() + + def send(self, bytes): + """ Send some bytes away """ + raise NotImplementedError() + + def receive(self, amount): + """ Get some bytes """ + raise NotImplementedError() + +class ANTUSBConnection(ANTConnection): + ep = { + 'in' : 0x81, + 'out' : 0x01 + } + + def __init__(self): + self._connection = False + self.timeout = 1000 + + def open(self): + self._connection = usb.core.find(idVendor = self.VID, + idProduct = self.PID) + if self._connection is None: + return False + + # For some reason, we have to set config, THEN reset, + # otherwise we segfault back in the ctypes (on linux, at + # least). + self._connection.set_configuration() + self._connection.reset() + # The we have to set our configuration again + self._connection.set_configuration() + + # Then we should get back a reset check, with 0x80 + # (SUSPEND_RESET) as our status + # + # I've commented this out because -- though it should just work + # it does seem to be causing some odd problems for me and does + # work with out it. Reed Wade - 31 Dec 2011 + ##self._check_reset_response(0x80) + return True + + def close(self): + if self._connection is not None: + self._connection = None + + def send(self, command): + # libusb expects ordinals, it'll redo the conversion itself. + c = command + self._connection.write(self.ep['out'], map(ord, c), 0, 100) + + def receive(self, amount): + return self._connection.read(self.ep['in'], amount, 0, self.timeout) + +class DynastreamANT(ANTUSBConnection): + """Class that represents the Dynastream USB stick base, for + garmin/suunto equipment. Only needs to set VID/PID. + + """ + VID = 0x0fcf + PID = 0x1008 + NAME = "Dynastream" + +class FitBitANT(ANTUSBConnection): + """Class that represents the fitbit base. Due to the extra + hardware to handle tracker connection and charging, has an extra + initialization sequence. + + """ + + VID = 0x10c4 + PID = 0x84c4 + NAME = "FitBit" + + def open(self): + if not super(FitBitANT, self).open(): + return False + self.init() + return True + + def init(self): + # Device setup + # bmRequestType, bmRequest, wValue, wIndex, data + self._connection.ctrl_transfer(0x40, 0x00, 0xFFFF, 0x0, []) + self._connection.ctrl_transfer(0x40, 0x01, 0x2000, 0x0, []) + # At this point, we get a 4096 buffer, then start all over + # again? Apparently doesn't require an explicit receive + self._connection.ctrl_transfer(0x40, 0x00, 0x0, 0x0, []) + self._connection.ctrl_transfer(0x40, 0x00, 0xFFFF, 0x0, []) + self._connection.ctrl_transfer(0x40, 0x01, 0x2000, 0x0, []) + self._connection.ctrl_transfer(0x40, 0x01, 0x4A, 0x0, []) + # Receive 1 byte, should be 0x2 + self._connection.ctrl_transfer(0xC0, 0xFF, 0x370B, 0x0, 1) + self._connection.ctrl_transfer(0x40, 0x03, 0x800, 0x0, []) + self._connection.ctrl_transfer(0x40, 0x13, 0x0, 0x0, \ + [0x08, 0x00, 0x00, 0x00, + 0x40, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + ]) + self._connection.ctrl_transfer(0x40, 0x12, 0x0C, 0x0, []) + try: + self.receive(1024) + except usb.USBError: + pass + +CONNS = [FitBitANT, DynastreamANT] + + +def getConn(): + for conn in [bc() for bc in CONNS]: + if conn.open(): + os.write(sys.stdout.fileno(), "\n%s: " % conn.NAME) + return conn + print "Failed to find a base" + return None diff --git a/python/antprotocol/libusb.py b/python/antprotocol/libusb.py deleted file mode 100644 index 283f442..0000000 --- a/python/antprotocol/libusb.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python -################################################################# -# pyusb access for ant devices -# By Kyle Machulis -# http://www.nonpolynomial.com -# -# Licensed under the BSD License, as follows -# -# Copyright (c) 2011, Kyle Machulis/Nonpolynomial Labs -# All rights reserved. -# -# Redistribution and use in source and binary forms, -# with or without modification, are permitted provided -# that the following conditions are met: -# -# * Redistributions of source code must retain the -# above copyright notice, this list of conditions -# and the following disclaimer. -# * Redistributions in binary form must reproduce the -# above copyright notice, this list of conditions and -# the following disclaimer in the documentation and/or -# other materials provided with the distribution. -# * Neither the name of the Nonpolynomial Labs nor the names -# of its contributors may be used to endorse or promote -# products derived from this software without specific -# prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND -# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, -# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT -# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR -# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, -# EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -################################################################# -# - -from protocol import ANT -import usb - -class ANTlibusb(ANT): - ep = { 'in' : 0x81, \ - 'out' : 0x01 - } - - def __init__(self, chan=0x0, debug=False): - super(ANTlibusb, self).__init__(chan, debug) - self._connection = False - self.timeout = 1000 - - def open(self, vid = None, pid = None): - if vid is None: - vid = self.VID - if pid is None: - pid = self.PID - self._connection = usb.core.find(idVendor = vid, - idProduct = pid) - if self._connection is None: - return False - - # For some reason, we have to set config, THEN reset, - # otherwise we segfault back in the ctypes (on linux, at - # least). - self._connection.set_configuration() - self._connection.reset() - # The we have to set our configuration again - self._connection.set_configuration() - - # Then we should get back a reset check, with 0x80 - # (SUSPEND_RESET) as our status - # - # I've commented this out because -- though it should just work - # it does seem to be causing some odd problems for me and does - # work with out it. Reed Wade - 31 Dec 2011 - ##self._check_reset_response(0x80) - return True - - def close(self): - if self._connection is not None: - self._connection = None - - def _send(self, command): - # libusb expects ordinals, it'll redo the conversion itself. - c = command - self._connection.write(self.ep['out'], map(ord, c), 0, 100) - - def _receive(self, size=4096): - return self._connection.read(self.ep['in'], size, 0, self.timeout) diff --git a/python/antprotocol/message.py b/python/antprotocol/message.py new file mode 100644 index 0000000..bf307c8 --- /dev/null +++ b/python/antprotocol/message.py @@ -0,0 +1,114 @@ +import operator + +UNASSIGN_CHANNEL = 0x41 +ASSIGN_CHANNEL = 0x42 +CHANNEL_ID = 0x51 +CHANNEL_PERIOD = 0x43 +SEARCH_TIMEOUT = 0x44 +CHANNEL_RF_FREQ = 0x45 +SET_NETWORK = 0x46 +TRANSMIT_POWER = 0x47 +ID_LIST_ADD = 0x59 +ID_LIST_CONFIG = 0x5A +CHANNEL_TX_POWER = 0x60 +LOW_PRIORITY_SEARCH_TIMEOUT = 0x63 +SERIAL_NUMBER_SET_CHANNEL_ID = 0x65 +ENABLE_EXT_RX_MESGS = 0x66 +ENABLE_LED = 0x68 +CRYSTAL_ENABLE = 0x6D +LIB_CONFIG = 0x6E +FREQUENCY_AGILITY = 0x70 +PROXIMITY_SEARCH = 0x71 +CHANNEL_SEARCH_PRIORITY = 0x75 + +STARTUP_MESSAGE = 0x6F +SERIAL_ERROR_MESSAGE = 0xAE + +SYSTEM_RESET = 0x4A +OPEN_CHANNEL = 0x4B +CLOSE_CHANNEL = 0x4C +OPEN_RX_SCAN_MODE = 0x5B +REQUEST_MESSAGE = 0x4D +SLEEP_MESSAGE = 0xC5 + +BROADCAST_DATA = 0x4E +ACKNOWLEDGE_DATA = 0x4F +BURST_TRANSFER_DATA = 0x50 + +CHANNEL_RESPONSE = 0x40 + +RESP_CHANNEL_STATUS = 0x52 +RESP_CHANNEL_ID = 0x51 +RESP_ANT_VERSION = 0x3E +RESP_CAPABILITIES = 0x54 +RESP_SERIAL_NUMBER = 0x61 + +CW_INIT = 0x53 +CW_TEST = 0x48 + +class Message(object): + def __init__(self): + self.sync = 0xa4 + try: + # for MessageOUT + self.len = 0 + self.cs = 0 + except AttributeError: + pass + self.id = None + self.data = [] + + def _raw(self, CS=False): + raw = [self.sync, self.len, self.id] + self.data + if CS: + raw.append(self.cs) + return raw + + def check_CS(self): + return reduce(operator.xor, self._raw()) == self.cs + + def toBytes(self): + return map(chr, self._raw(True)) + + def __str__(self): + return ' '.join(['%02X' % x for x in self._raw(True)]) + + +class MessageIN(Message): + def __init__(self, raw): + Message.__init__(self) + assert len(raw) >= 4 + assert raw[1] == len(raw) - 4 + self.sync = raw[0] + self.len = raw[1] + self.id = raw[2] + self.data = raw[3:-1] + self.cs = raw[-1] + + def __str__(self): + return '<== ' + Message.__str__(self) + +class MessageOUT(Message): + def __init__(self, msgid, *data): + Message.__init__(self) + self.id = msgid + + for l in list(data): + if isinstance(l, list): + self.data += l + else: + self.data.append(l) + + + @property + def cs(self): + return reduce(operator.xor, self._raw()) + + + @property + def len(self): + return len(self.data) + + + def __str__(self): + return '==> ' + Message.__str__(self) diff --git a/python/antprotocol/protocol.py b/python/antprotocol/protocol.py index 7db0751..147f47b 100644 --- a/python/antprotocol/protocol.py +++ b/python/antprotocol/protocol.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python ################################################################# # ant message protocol # By Kyle Machulis @@ -45,102 +44,109 @@ # Added to and untwistedized and fixed up by Kyle Machulis # -import operator, struct, array, time +import struct, array, time, os, sys +from message import MessageIN, MessageOUT -class ANTReceiveException(Exception): - pass +class ANTException(Exception): + """ Our Base Exception class """ + +class ReceiveException(ANTException): pass + +class StatusException(ReceiveException): pass + +class NoMessageException(ReceiveException): pass + +class FitBitBeaconTimeout(ReceiveException): pass + +class SendException(ANTException): pass def hexList(data): return map(lambda s: chr(s).encode('HEX'), data) def hexRepr(data): - return repr(hexList(data)) + return ' '.join(hexList(data)).upper() def intListToByteList(data): return map(lambda i: struct.pack('!H', i)[1], array.array('B', data)) -class ANTStatusException(Exception): - pass - def log(f): def wrapper(self, *args, **kwargs): if self._debug: - print "Start", f.__name__, args, kwargs + print ' '*self._loglevel, "Start", f.__name__, args, kwargs + self._loglevel += 1 try: res = f(self, *args, **kwargs) except: if self._debug: - print "Fail", f.__name__ + print ' '*self._loglevel, "Fail", f.__name__ raise + finally: + self._loglevel -= 1 if self._debug: - print "End", f.__name__, res + print ' '*self._loglevel, "End", f.__name__, res return res return wrapper class ANT(object): - def __init__(self, chan=0x00, debug=False): + def __init__(self, connection, chan=0x00, debug=False): + self.connection = connection self._debug = debug self._chan = chan self._state = 0 self._receiveBuffer = [] + self._loglevel = 0 def _event_to_string(self, event): - try: - return { 0:"RESPONSE_NO_ERROR", - 1:"EVENT_RX_SEARCH_TIMEOUT", - 2:"EVENT_RX_FAIL", - 3:"EVENT_TX", - 4:"EVENT_TRANSFER_RX_FAILED", - 5:"EVENT_TRANSFER_TX_COMPLETED", - 6:"EVENT_TRANSFER_TX_FAILED", - 7:"EVENT_CHANNEL_CLOSED", - 8:"EVENT_RX_FAIL_GO_TO_SEARCH", - 9:"EVENT_CHANNEL_COLLISION", - 10:"EVENT_TRANSFER_TX_START", - 21:"CHANNEL_IN_WRONG_STATE", - 22:"CHANNEL_NOT_OPENED", - 24:"CHANNEL_ID_NOT_SET", - 25:"CLOSE_ALL_CHANNELS", - 31:"TRANSFER_IN_PROGRESS", - 32:"TRANSFER_SEQUENCE_NUMBER_ERROR", - 33:"TRANSFER_IN_ERROR", - 40:"INVALID_MESSAGE", - 41:"INVALID_NETWORK_NUMBER", - 48:"INVALID_LIST_ID", - 49:"INVALID_SCAN_TX_CHANNEL", - 51:"INVALID_PARAMETER_PROVIDED", - 53:"EVENT_QUE_OVERFLOW", - 64:"NVM_FULL_ERROR", - 65:"NVM_WRITE_ERROR", - 66:"ASSIGN_CHANNEL_ID", - 81:"SET_CHANNEL_ID", - 0x4b:"OPEN_CHANNEL"}[event] - except: - return "%02x" % event + return { 0:"RESPONSE_NO_ERROR", + 1:"EVENT_RX_SEARCH_TIMEOUT", + 2:"EVENT_RX_FAIL", + 3:"EVENT_TX", + 4:"EVENT_TRANSFER_RX_FAILED", + 5:"EVENT_TRANSFER_TX_COMPLETED", + 6:"EVENT_TRANSFER_TX_FAILED", + 7:"EVENT_CHANNEL_CLOSED", + 8:"EVENT_RX_FAIL_GO_TO_SEARCH", + 9:"EVENT_CHANNEL_COLLISION", + 10:"EVENT_TRANSFER_TX_START", + 21:"CHANNEL_IN_WRONG_STATE", + 22:"CHANNEL_NOT_OPENED", + 24:"CHANNEL_ID_NOT_SET", + 25:"CLOSE_ALL_CHANNELS", + 31:"TRANSFER_IN_PROGRESS", + 32:"TRANSFER_SEQUENCE_NUMBER_ERROR", + 33:"TRANSFER_IN_ERROR", + 40:"INVALID_MESSAGE", + 41:"INVALID_NETWORK_NUMBER", + 48:"INVALID_LIST_ID", + 49:"INVALID_SCAN_TX_CHANNEL", + 51:"INVALID_PARAMETER_PROVIDED", + 53:"EVENT_QUE_OVERFLOW", + 64:"NVM_FULL_ERROR", + 65:"NVM_WRITE_ERROR", + 66:"ASSIGN_CHANNEL_ID", + 81:"SET_CHANNEL_ID", + 0x4b:"OPEN_CHANNEL"}.get(event, "%02x" % event) def _check_reset_response(self, status): for tries in range(8): try: - data = self._receive_message() - except ANTReceiveException: + msg = self._receive_message() + except ReceiveException: continue - if len(data) > 3 and data[2] == 0x6f and data[3] == status: + if msg.id == 0x6f and msg.data[0] == status: return - raise ANTStatusException("Failed to detect reset response") + raise StatusException("Failed to detect reset response") - def _check_ok_response(self): + def _check_ok_response(self, msgid): # response packets will always be 7 bytes - status = self._receive_message() + msg = self._receive_message() - if len(status) == 0: - raise ANTStatusException("No message response received!") - - if status[2] == 0x40 and status[5] == 0x0: + if msg.id == 0x40 and msg.len == 3 and msg.data[1:3] == [msgid, 0x00]: return - raise ANTStatusException("Message status %d does not match 0x0 (NO_ERROR)" % (status[5])) + raise StatusException("Message status %s does not match 0x0, 0x%x, 0x0 (NO_ERROR)" % (msg.data, msgid)) @log def reset(self): @@ -162,68 +168,82 @@ def reset(self): @log def set_channel_frequency(self, freq): self._send_message(0x45, self._chan, freq) - self._check_ok_response() + self._check_ok_response(0x45) @log def set_transmit_power(self, power): self._send_message(0x47, 0x0, power) - self._check_ok_response() + self._check_ok_response(0x47) @log def set_search_timeout(self, timeout): self._send_message(0x44, self._chan, timeout) - self._check_ok_response() + self._check_ok_response(0x44) @log def send_network_key(self, network, key): self._send_message(0x46, network, key) - self._check_ok_response() + self._check_ok_response(0x46) @log def set_channel_period(self, period): self._send_message(0x43, self._chan, period) - self._check_ok_response() + self._check_ok_response(0x43) @log def set_channel_id(self, id): self._send_message(0x51, self._chan, id) - self._check_ok_response() + self._check_ok_response(0x51) @log def open_channel(self): self._send_message(0x4b, self._chan) - self._check_ok_response() + self._check_ok_response(0x4b) @log def close_channel(self): self._send_message(0x4c, self._chan) - self._check_ok_response() + self._check_ok_response(0x4c) @log def assign_channel(self): self._send_message(0x42, self._chan, 0x00, 0x00) - self._check_ok_response() + self._check_ok_response(0x42) @log def receive_acknowledged_reply(self, size = 13): for tries in range(30): - status = self._receive_message(size) - if len(status) > 4 and status[2] == 0x4F: - return status[4:-1] - raise ANTReceiveException("Failed to receive acknowledged reply") + msg = self._receive_message(size) + if msg.len > 0 and msg.id == 0x4F: + return msg.data[1:] + raise ReceiveException("Failed to receive acknowledged reply") @log def _check_tx_response(self, maxtries = 16): for msgs in range(maxtries): - status = self._receive_message() - if len(status) > 5 and status[2] == 0x40: - if status[5] == 0x0a: # TX Start + msg = self._receive_message() + if msg.len > 1 and msg.id == 0x40: + if msg.data[2] == 0x0a: # TX Start continue - if status[5] == 0x05: # TX successful + if msg.data[2] == 0x05: # TX successful return - if status[5] == 0x06: # TX failed - raise ANTReceiveException("Transmission Failed") - raise ANTReceiveException("No Transmission Ack Seen") + if msg.data[2] == 0x06: # TX failed + raise ReceiveException("Transmission Failed") + raise ReceiveException("No Transmission Ack Seen") + + @log + def receive_bdcast(self): + # FitBit device initialization + for tries in range(60): + os.write(sys.stdout.fileno(), '.') + try: + msg = self._receive_message() + except NoMessageException: + continue + if msg.id == 0x4E: + os.write(sys.stdout.fileno(), '!') + return + raise FitBitBeaconTimeout("Timeout waiting for beacon, will restart") @log def _send_burst_data(self, data, sleep = None): @@ -235,26 +255,26 @@ def _send_burst_data(self, data, sleep = None): time.sleep(sleep) try: self._check_tx_response() - except ANTReceiveException: + except ReceiveException: continue return - raise ANTReceiveException("Failed to send burst data") + raise ReceiveException("Failed to send burst data") @log def _check_burst_response(self): response = [] for tries in range(128): - status = self._receive_message() - if len(status) > 5 and status[2] == 0x40 and status[5] == 0x4: - raise ANTReceiveException("Burst receive failed by event!") - elif len(status) > 4 and status[2] == 0x4f: - response = response + status[4:-1] + msg = self._receive_message() + if msg.len > 1 and msg.id == 0x40 and msg.data[2] == 0x4: + raise ReceiveException("Burst receive failed by event!") + elif msg.len > 0 and msg.id == 0x4f: + response = response + msg.data[1:] return response - elif len(status) > 4 and status[2] == 0x50: - response = response + status[4:-1] - if status[3] & 0x80: + elif msg.len > 0 and msg.id == 0x50: + response = response + msg.data[1:] + if msg.data[0] & 0x80: return response - raise ANTReceiveException("Burst receive failed to detect end") + raise ReceiveException("Burst receive failed to detect end") @log def send_acknowledged_data(self, l): @@ -262,31 +282,22 @@ def send_acknowledged_data(self, l): try: self._send_message(0x4f, self._chan, l) self._check_tx_response() - except ANTReceiveException: + except ReceiveException: continue return - raise ANTReceiveException("Failed to send Acknowledged Data") + raise ReceiveException("Failed to send Acknowledged Data") def send_str(self, instring): if len(instring) > 8: raise "string is too big" - return self._send_message(*[0x4e] + list(struct.unpack('%sB' % len(instring), instring))) - - def _send_message(self, *args): - data = list() - for l in list(args): - if isinstance(l, list): - data = data + l - else: - data.append(l) - data.insert(0, len(data) - 1) - data.insert(0, 0xa4) - data.append(reduce(operator.xor, data)) + return self._send_message(0x4e, list(struct.unpack('%sB' % len(instring), instring))) + def _send_message(self, msgid, *args): + msg = MessageOUT(msgid, *args) if self._debug: - print " sent: " + hexRepr(data) - return self._send(map(chr, array.array('B', data))) + print ' '*self._loglevel, msg + return self.connection.send(msg.toBytes()) def _find_sync(self, buf, start=0): i = 0; @@ -309,7 +320,7 @@ def _receive_message(self, size = 4096): # data[] too small, try to read some more from usb.core import USBError try: - data += self._receive(size).tolist() + data += self.connection.receive(size).tolist() timeouts = 0 except USBError: timeouts = timeouts+1 @@ -322,7 +333,7 @@ def _receive_message(self, size = 4096): if len(data) == 0: # Failed to find anything.. self._receiveBuffer = [] - return [] + raise NoMessageException() continue data = self._find_sync(data) if len(data) < l: continue @@ -333,20 +344,15 @@ def _receive_message(self, size = 4096): l = data[1] + 4 if len(data) < l: continue - p = data[0:l] - if reduce(operator.xor, p) != 0: + msg = MessageIN(data[:l]) + if not msg.check_CS(): if self._debug: - print "Checksum error for proposed packet: " + hexRepr(p) + print "Checksum error for proposed packet: ", msg data = self._find_sync(data, 1) continue + # save the rest for later self._receiveBuffer = data[l:] if self._debug: - print "received: " + hexRepr(p) - return p - - def _receive(self, size=4096): - raise Exception("Need to define _receive function for ANT child class!") - - def _send(self): - raise Exception("Need to define _send function for ANT child class!") + print ' '*self._loglevel, msg + return msg diff --git a/python/fitbit.py b/python/fitbit.py index 6956ead..c40ffc2 100755 --- a/python/fitbit.py +++ b/python/fitbit.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python ################################################################# # python fitbit object # By Kyle Machulis @@ -67,13 +66,12 @@ # - Implementing data clearing import itertools, sys, random, operator, datetime, time -from antprotocol.bases import FitBitANT, DynastreamANT -from antprotocol.protocol import ANTReceiveException +from antprotocol.protocol import ANTException, ReceiveException, SendException class FitBit(object): """Class to represent the fitbit tracker device, the portion of the fitbit worn by the user. Stores information about the tracker - (serial number, firmware version, etc...). + (serial number, hardware version, etc...). """ @@ -92,8 +90,8 @@ def __init__(self, base = None): self.current_packet_id = None #: serial number of the tracker self.serial = None - #: firmware version loaded on the tracker - self.firmware_version = None + #: hardware version loaded on the tracker + self.hardware_version = None #: Major version of BSL (?) self.bsl_major_version = None #: Minor version of BSL (?) @@ -122,7 +120,7 @@ def parse_info_packet(self, data): """Parses the information gotten from the 0x24 retrieval command""" self.serial = data[0:5] - self.firmware_version = data[5] + self.hardware_version = data[5] self.bsl_major_version = data[6] self.bsl_minor_version = data[7] self.app_major_version = data[8] @@ -134,13 +132,13 @@ def __str__(self): """Returns string representation of tracker information""" return "Tracker Serial: %s\n" \ - "Firmware Version: %d\n" \ + "Hardware Version: %d\n" \ "BSL Version: %d.%d\n" \ "APP Version: %d.%d\n" \ "In Mode BSL? %s\n" \ "On Charger? %s\n" % \ ("".join(["%x" % (x) for x in self.serial]), - self.firmware_version, + self.hardware_version, self.bsl_major_version, self.bsl_minor_version, self.app_major_version, @@ -185,32 +183,24 @@ def command_sleep(self): self.base.send_acknowledged_data([0x7f, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c]) def wait_for_beacon(self): - # FitBit device initialization - print "Waiting for receive" - for tries in range(75): - try: - d = self.base._receive_message() - if d[2] == 0x4E: - return - except Exception: - pass - raise ANTReceiveException("Failed to see tracker beacon") + self.base.receive_bdcast() def _get_tracker_burst(self): d = self.base._check_burst_response() if d[1] != 0x81: - raise Exception("Response received is not tracker burst! Got %s" % (d[0:2])) + raise ReceiveException("Response received is not tracker burst! Got %s" % (d[0:2])) size = d[3] << 8 | d[2] if size == 0: return [] return d[8:8+size] - def run_opcode(self, opcode, payload = None): + def run_opcode(self, opcode, payload = []): for tries in range(4): try: self.send_tracker_packet(opcode) data = self.base.receive_acknowledged_reply() - except: + except ANTException, ae: + print 'Failed to send Opcode %s : ' % opcode, ae continue if data[0] != self.current_packet_id: print "Tracker Packet IDs don't match! %02x %02x" % (data[0], self.current_packet_id) @@ -219,21 +209,19 @@ def run_opcode(self, opcode, payload = None): return self.get_data_bank() if data[1] == 0x61: # Send payload data to device - if payload is not None: + if len(payload) > 0: self.send_tracker_payload(payload) data = self.base.receive_acknowledged_reply() - data.pop(0) - return data - raise Exception("run_opcode: opcode %s, no payload" % (opcode)) + return data[1:] + raise SendException("run_opcode: opcode %s, no payload" % (opcode)) if data[1] == 0x41: - data.pop(0) - return data - raise Exception("Failed to run opcode %s" % (opcode)) + return data[1:] + raise ANTException("Failed to run opcode %s" % (opcode)) def send_tracker_payload(self, payload): # The first packet will be the packet id, the length of the - # payload, and ends with the payload CRC - p = [0x00, self.gen_packet_id(), 0x80, len(payload), 0x00, 0x00, 0x00, 0x00, reduce(operator.xor, map(ord, payload))] + # payload, and ends with the payload checksum + p = [0x00, self.gen_packet_id(), 0x80, len(payload), 0x00, 0x00, 0x00, 0x00, reduce(operator.xor, payload)] prefix = itertools.cycle([0x20, 0x40, 0x60]) for i in range(0, len(payload), 8): current_prefix = prefix.next() @@ -242,7 +230,7 @@ def send_tracker_payload(self, payload): plist += [(current_prefix + 0x80) | self.base._chan] else: plist += [current_prefix | self.base._chan] - plist += map(ord, payload[i:i+8]) + plist += payload[i:i+8] while len(plist) < 9: plist += [0x0] p += plist @@ -288,13 +276,7 @@ def get_data_bank(self): if len(bank) == 0: return data data = data + bank - raise ANTReceiveException("Cannot complete data bank") - - def parse_bank2_data(self, data): - for i in range(0, len(data), 13): - print ["0x%.02x" % x for x in data[i:i+13]] - # First 4 bytes are seconds from Jan 1, 1970 - print "Time: %s" % (datetime.datetime.fromtimestamp(data[i] | data[i + 1] << 8 | data[i + 2] << 16 | data[i + 3] << 24)) + raise ReceiveException("Cannot complete data bank") def parse_bank0_data(self, data): # First 4 bytes are a time @@ -325,12 +307,46 @@ def parse_bank0_data(self, data): time_index = time_index + 1 def parse_bank1_data(self, data): - for i in range(0, len(data), 14): - print ["0x%.02x" % x for x in data[i:i+13]] + ultra = self.hardware_version >= 12 + banklen = {12:16}.get(self.hardware_version, 14) + for i in range(0, len(data), banklen): + d = data[i:i+banklen] + # First 4 bytes are seconds from Jan 1, 1970 + maybe_calories = d[5] << 8| d[4] + daily_steps = d[9] << 24 | d[8] << 16 | d[7] << 8 | d[6] + daily_dist = (d[13] << 24 | d[12] << 16 | d[11] << 8 | d[10]) / 1000000. + daily_floors = 0 + if ultra: + daily_floors = (d[15] << 8 | d[14]) / 10 + record_date = datetime.datetime.fromtimestamp(d[0] | d[1] << 8 | d[2] << 16 | d[3] << 24) + print "Time: %s %d Daily Steps: %d, Daily distance: %fkm Daily floors: %d" % ( + record_date, maybe_calories, daily_steps, daily_dist, daily_floors) + + def parse_bank2_data(self, data): + ultra = self.hardware_version >= 12 + banklen = {12:15}.get(self.hardware_version, 13) + for i in range(0, len(data), banklen): + d = data[i:i+banklen] # First 4 bytes are seconds from Jan 1, 1970 - daily_steps = data[i+7] << 8 | data[i+6] - record_date = datetime.datetime.fromtimestamp(data[i] | data[i + 1] << 8 | data[i + 2] << 16 | data[i + 3] << 24) - print "Time: %s Daily Steps: %d" % (record_date, daily_steps) + print "Time: %s" % (datetime.datetime.fromtimestamp(d[0] | d[1] << 8 | d[2] << 16 | d[3] << 24)) + if d[6] == 1: + elapsed = (d[5] << 8) | d[4] + steps = (d[9]<< 16) | (d[8] << 8) | d[7] + dist = (d[12] << 16) | (d[11]<< 8) | d[10] + floors = 0 + if ultra: + floors = ((d[14] << 8) | d[13]) / 10 + print "Activity summary: duration: %s, %d steps, %fkm, %d floors" % ( + datetime.timedelta(seconds=elapsed), steps, dist / 100000., floors / 10) + else: + print ' '.join(['%02X' % x for x in d[4:]]) + + + def parse_bank4_data(self, data): + assert len(data) == 64 + print ' '.join(["0x%.02x" % x for x in data[:24]]) + print "Greeting : ", ''.join([chr(x) for x in data[24:24+8]]) + print "Chatter: ", ', '.join([''.join([chr(x) for x in data[i:i+8]]) for i in range(34, 64, 10)]) def parse_bank6_data(self, data): i = 0 @@ -346,41 +362,16 @@ def parse_bank6_data(self, data): tstamp = d[3] | d[2] << 8 | d[1] << 16 | d[0] << 24 i += 4 -def main(): - #base = DynastreamANT(True) - base = FitBitANT(debug=True) - if not base.open(): - print "No devices connected!" - return 1 - - device = FitBit(base) - - device.init_tracker_for_transfer() - - device.get_tracker_info() - # print device.tracker - - device.parse_bank2_data(device.run_data_bank_opcode(0x02)) - print "---" - device.parse_bank0_data(device.run_data_bank_opcode(0x00)) - device.run_data_bank_opcode(0x04) - d = device.run_data_bank_opcode(0x02) # 13 - for i in range(0, len(d), 13): - print ["%02x" % x for x in d[i:i+13]] - d = device.run_data_bank_opcode(0x00) # 7 - print ["%02x" % x for x in d[0:7]] - print ["%02x" % x for x in d[7:14]] - j = 0 - for i in range(14, len(d), 3): - print d[i:i+3] - j += 1 - print "Records: %d" % (j) - device.parse_bank1_data(device.run_data_bank_opcode(0x01)) - - # for i in range(0, len(d), 14): - # print ["%02x" % x for x in d[i:i+14]] - base.close() - return 0 - -if __name__ == '__main__': - sys.exit(main()) + + def write_settings(self, options ,greetings = "", chatter = []): + greetings = greetings.ljust( 8, '\0') + for i in range(max(len(chatter), 3)): + chatter[i] = chatter[i].ljust(8, '\0') + payload = [] + if False: # not ready yet + self.write_bank(4, payload) + + def write_bank(self, index, data): + self.run_opcode([0x25, index, len(data), 0,0,0,0], data) + +# vim: set ts=4 sw=4 expandtab: diff --git a/python/fitbit_client.py b/python/fitbit_client.py index 56f6ac8..8df4adc 100755 --- a/python/fitbit_client.py +++ b/python/fitbit_client.py @@ -44,142 +44,271 @@ # EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ################################################################# +import os +import time +import yaml import sys import urllib import urllib2 import urlparse import base64 +import argparse import xml.etree.ElementTree as et from fitbit import FitBit -from antprotocol.bases import FitBitANT, DynastreamANT +from antprotocol.connection import getConn +from antprotocol.protocol import ANT, ANTException, FitBitBeaconTimeout -class FitBitResponse(object): - def __init__(self, response): +class FitBitRequest(object): + + def __init__(self, host, path, https = False, response = None, opcodes = []): self.current_opcode = {} - self.opcodes = [] - self.root = et.fromstring(response.strip()) - self.host = None - self.path = None - self.response = None - if self.root.find("response") is not None: - self.host = self.root.find("response").attrib["host"] - self.path = self.root.find("response").attrib["path"] - if self.root.find("response").text: - # Quick and dirty url encode split - response = self.root.find("response").text - self.response = dict(urlparse.parse_qsl(response)) - - for opcode in self.root.findall("device/remoteOps/remoteOp"): - op = {} - op["opcode"] = [ord(x) for x in base64.b64decode(opcode.find("opCode").text)] - op["payload"] = None - if opcode.find("payloadData").text is not None: - op["payload"] = [x for x in base64.b64decode(opcode.find("payloadData").text)] - self.opcodes.append(op) - + self.opcodes = opcodes + self.response = response + self.host = host + self.path = path + if https: + scheme = 'https://' + else: + scheme = 'http://' + self.url = scheme + host + path + + def upload(self, params): + data = urllib.urlencode(params) + req = urllib2.urlopen(self.url, data) + self.rawresponse = req.read() + + def getNext(self): + root = et.fromstring(self.rawresponse.strip()) + xmlresponse = root.find("response") + if xmlresponse is None: + print "That was it." + return None + + host = xmlresponse.attrib["host"] + path = xmlresponse.attrib["path"] + response = xmlresponse.text + if response: + response = dict(urlparse.parse_qsl(response)) + + opcodes = [] + for remoteop in root.findall("device/remoteOps/remoteOp"): + opcodes.append(RemoteOp(remoteop)) + + return FitBitRequest(host, path, response=response, opcodes=opcodes) + + def run_opcodes(self, fitbit): + res = {} + op_index = 0 + for op in self.opcodes: + try: + op.run(fitbit) + res["opResponse[%d]" % op_index] = op.response + res["opStatus[%d]" % op_index] = op.status + except ANTException: + print "failed running", op.dump() + break + + op_index += 1 + return res + + def dump(self): + ops = [] + for op in self.opcodes: + ops.append(op.dump()) + return ops + def __repr__(self): - return "" % (id(self), str(self.opcodes), str(self.response)) + return "" % (id(self), str(self.opcodes), str(self.response)) + +class RemoteOp(object): + def __init__(self, data): + opcode = base64.b64decode(data.find("opCode").text) + self.opcode = [ord(x) for x in opcode] + self.status = "failed" + self.payload = None + self.rawresponse = [] + self.response = '' + payload = data.find("payloadData").text + if payload is not None: + payload = base64.b64decode(payload) + self.payload = [ord(x) for x in payload] + + def run(self, fitbit): + self.rawresponse = fitbit.run_opcode(self.opcode, self.payload) + response = [chr(x) for x in self.rawresponse] + self.response = base64.b64encode(''.join(response)) + self.status = "success" + + def dump(self): + return {'request': + {'opcode': self.opcode, + 'payload': self.payload}, + 'status': self.status, + 'response': self.rawresponse} class FitBitClient(object): CLIENT_UUID = "2ea32002-a079-48f4-8020-0badd22939e3" - #FITBIT_HOST = "http://client.fitbit.com:80" - FITBIT_HOST = "https://client.fitbit.com" # only used for initial request + FITBIT_HOST = "client.fitbit.com" START_PATH = "/device/tracker/uploadData" - DEBUG = True - BASES = [FitBitANT, DynastreamANT] - def __init__(self): + def __init__(self, debug=False): self.info_dict = {} - self.fitbit = None - for base in [bc(debug=self.DEBUG) for bc in self.BASES]: - for retries in (2,1,0): - try: - if base.open(): - print "Found %s base" % (base.NAME,) - self.fitbit = FitBit(base) - self.remote_info = None - break - else: - break - except Exception, e: - print e - if retries: - print "retrying" - time.sleep(5) - else: - raise - if self.fitbit: - break + self.log_info = {} + self.time = time.time() + self.data = [] + conn = getConn() + if conn is None: + print "No base found!" + exit(1) + base = ANT(conn) + self.fitbit = FitBit(base) if not self.fitbit: print "No devices connected!" exit(1) - def form_base_info(self): + def __del__(self): + self.close() + self.fitbit = None + + def form_base_info(self, remote_info=None): self.info_dict.clear() self.info_dict["beaconType"] = "standard" self.info_dict["clientMode"] = "standard" self.info_dict["clientVersion"] = "1.0" self.info_dict["os"] = "libfitbit" self.info_dict["clientId"] = self.CLIENT_UUID - if self.remote_info: - self.info_dict = dict(self.info_dict, **self.remote_info) + if remote_info: + self.info_dict.update(remote_info) + for f in ['deviceInfo.serialNumber','userPublicId']: + if f in self.info_dict: + self.log_info[f] = self.info_dict[f] + + def dump_connection(self, directory='~/.fitbit'): + directory = os.path.expanduser(directory) + data = yaml.dump(self.data) + if 'userPublicId' in self.log_info: + directory = os.path.join(directory, self.log_info['userPublicId']) + if not os.path.isdir(directory): + os.makedirs(directory) + f = open(os.path.join(directory,'connection-%d.txt' % int(self.time)), 'w') + f.write(data) + f.close() + print data - def run_upload_request(self): + def close(self): + self.dump_connection() + print 'Closing USB device' try: - self.fitbit.init_tracker_for_transfer() - - url = self.FITBIT_HOST + self.START_PATH - - # Start the request Chain - self.form_base_info() - while url is not None: - res = urllib2.urlopen(url, urllib.urlencode(self.info_dict)).read() - print res - r = FitBitResponse(res) - self.remote_info = r.response - self.form_base_info() - op_index = 0 - for o in r.opcodes: - self.info_dict["opResponse[%d]" % op_index] = base64.b64encode(''.join([chr(x) for x in self.fitbit.run_opcode(o["opcode"], o["payload"])])) - self.info_dict["opStatus[%d]" % op_index] = "success" - op_index += 1 - urllib.urlencode(self.info_dict) - print self.info_dict - if r.host: - url = "http://%s%s" % (r.host, r.path) - print url - else: - print "No URL returned. Quitting." - break - except: - self.fitbit.base.close() - raise + self.fitbit.base.connection.close() + except AttributeError: + pass + self.fitbit.base = None + + def run_upload_requests(self): + self.fitbit.init_tracker_for_transfer() + + conn = FitBitRequest(self.FITBIT_HOST, self.START_PATH, https=True) + + # Start the request Chain + self.form_base_info() + while conn is not None: + self.form_base_info(conn.response) + + self.info_dict.update(conn.run_opcodes(self.fitbit)) + + conn.upload(self.info_dict) + + self.data.append(conn.dump()) + conn = conn.getNext() + self.fitbit.command_sleep() - self.fitbit.base.close() -def main(): - f = FitBitClient() - f.run_upload_request() - return 0 +class FitBitDaemon(object): -if __name__ == '__main__': - import time - import traceback - - cycle_minutes = 15 - - while True: + def __init__(self, debug): + self.log_info = {} + self.log = None + self.debug = debug + + def do_sync(self): + f = FitBitClient(self.debug) + try: + f.run_upload_requests() + except: + f.close() + raise + f.close() + self.log_info = f.log_info + + def try_sync(self): + import traceback + import usb + self.log_info = {} try: - main() - except Exception, e: + self.do_sync() + except FitBitBeaconTimeout, e: + # This error is fairly normal, so we don't increase error counter. + print e + except ANTException, e: + # For ANT errors, log and increase error counter. print "Failed with", e print print '-'*60 traceback.print_exc(file=sys.stdout) print '-'*60 + self.write_log('ERROR: ' + str(e)) + self.errors += 1 + except usb.USBError, e: + # Raise this error up the stack, since USB errors are fairly + # critical. + self.write_log('ERROR: ' + str(e)) + raise else: + # Clear error counter after a successful sync. print "normal finish" - print "restarting..." - - #sys.exit(main()) + self.write_log('SUCCESS') + self.errors = 0 + + def run(self, args): + sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0) + self.errors = 0 + + while self.errors < 3: + self.open_log() + self.try_sync() + self.close_log() + if args.once: + print "I'm done" + return + time.sleep(3) + + print 'exiting due to earlier failure' + sys.exit(1) + + # + # Logging functions + # + + def open_log(self): + self.log = open('fitbit.log', 'a') + + def write_log(self, s): + self.log.write('[%s] [%s -> %s] %s\n' % (time.ctime(), \ + self.log_field('deviceInfo.serialNumber'), \ + self.log_field('userPublicId'), s)) + + def log_field(self, f): + return (self.log_info[f] if f in self.log_info else 'UNKNOWN') + + def close_log(self): + if (self.log): + self.log.close() + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("--once", help="Run the request only once", action="store_true") + parser.add_argument("--debug", help="Display debug information", action="store_true") + args = parser.parse_args() + FitBitDaemon(args.debug).run(args) +# vim: set ts=4 sw=4 expandtab: diff --git a/python/ifitbit.py b/python/ifitbit.py new file mode 100755 index 0000000..c2cc2d9 --- /dev/null +++ b/python/ifitbit.py @@ -0,0 +1,193 @@ +#! /usr/bin/env python + +import traceback, sys + +exit = False + +cmds = {} +helps = {} + +def command(cmd, help): + def decorator(fn): + cmds[cmd] = fn + helps[cmd] = help + def wrapped(*args): + return fn(*args) + return wrapped + return decorator + +@command('exit', 'Quit...') +def quit(): + print 'Bye !' + global exit + exit = True + +@command('help', 'Print possible commands') +def print_help(): + for cmd in sorted(helps.keys()): + print '%s\t%s' % (cmd, helps[cmd]) + +from antprotocol.connection import getConn +from antprotocol.protocol import ANT +from fitbit import FitBit +import time + +base = None +tracker = None + +def checktracker(fn): + def wrapped(*args): + if tracker is None: + print "No tracker, initialize first" + return + return fn(*args) + return wrapped + +def checkinfo(fn): + def wrapped(*args): + if tracker.hardware_version is None: + print "You first need to request the tracker info (info)" + return + return fn(*args) + return wrapped + +@command('init', 'Initialize the tracker') +def init(*args): + global tracker, base + debug = False + if len(args) >= 1: + debug = bool(int(args[0])) + if debug: print "Debug ON" + conn = getConn() + if conn is None: + print "No device connected." + return + base = ANT(conn) + tracker = FitBit(base) + tracker.init_tracker_for_transfer() + +@command('close', 'Close all connections') +def close(): + global base, tracker + if base is not None: + print "Closing connection" + base.connection.close() + base = None + tracker = None + +@command('test', 'Run a test from the old fitbit.py') +def test(): + global base + if base is None: + conn = getConn() + if conn is None: + print "No devices connected!" + return 1 + base = ANT(conn) + device = FitBit(base) + + device.init_tracker_for_transfer() + + device.get_tracker_info() + # print device.tracker + + device.parse_bank2_data(device.run_data_bank_opcode(0x02)) + print "---" + device.parse_bank0_data(device.run_data_bank_opcode(0x00)) + device.run_data_bank_opcode(0x04) + d = device.run_data_bank_opcode(0x02) # 13 + for i in range(0, len(d), 13): + print ["%02x" % x for x in d[i:i+13]] + d = device.run_data_bank_opcode(0x00) # 7 + print ["%02x" % x for x in d[0:7]] + print ["%02x" % x for x in d[7:14]] + j = 0 + for i in range(14, len(d), 3): + print d[i:i+3] + j += 1 + print "Records: %d" % (j) + device.parse_bank1_data(device.run_data_bank_opcode(0x01)) + + # for i in range(0, len(d), 14): + # print ["%02x" % x for x in d[i:i+14]] + base.connection.close() + base = None + +@command('>', 'Run opcode') +@checktracker +def opcode(*args): + args = list(args) + while len(args) < 7: + # make it a full opcode + args.append('0') + code = [int(x, 16) for x in args[:7]] + payload = [int(x, 16) for x in args[7:]] + print '==> ', code #' '.join(['%02X' % x for x in code]) + if payload: + print ' -> ', ' '.join(['%02X' % x for x in payload]) + res = tracker.run_opcode(code, payload) + print '<== ',' '.join(['%02X' % x for x in res]) + +@command('info', 'Get tracker info') +@checktracker +def get_info(): + tracker.get_tracker_info() + print tracker + +@command('read', 'Read data bank') +@checktracker +@checkinfo +def read_bank(index): + idx = int(index) + data = tracker.run_data_bank_opcode(idx) + def pprint(data): + print ' '.join(["%02X" % x for x in data]) + {0: tracker.parse_bank0_data, + 1: tracker.parse_bank1_data, + 2: tracker.parse_bank2_data, +# 3: tracker.parse_bank3_data, + 4: tracker.parse_bank4_data, +# 5: tracker.parse_bank5_data, + 6: tracker.parse_bank6_data, + }.get(idx, pprint)(data) + +@command('pr5', 'Periodic read 5') +@checktracker +def pr5(sleep = '5', repeat = '100'): + sleep = int(sleep) + repeat = int(repeat) + while repeat > 0: + read_bank(5) + time.sleep(sleep) + repeat -= 1 + +@command('erase', 'Erase data bank') +@checktracker +def erase_bank(index, tstamp=None): + idx = int(index) + if tstamp is not None: + tstamp = int(tstamp) + data = tracker.erase_data_bank(idx, tstamp) + if data != [65, 0, 0, 0, 0, 0, 0]: + print "Bad", data + return + print "Done" + +while not exit: + input = raw_input('> ') + input = input.split(' ') + try: + f = cmds[input[0]] + except KeyError: + print 'Command %s not known' % input[0] + print_help() + continue + try: + f(*input[1:]) + except Exception, e: + # We need that to be able to close the connection nicely + print "BaD bAd BAd", e + traceback.print_exc(file=sys.stdout) + exit = True + +close() diff --git a/svscan/install.sh b/svscan/install.sh new file mode 100644 index 0000000..a6a0c11 --- /dev/null +++ b/svscan/install.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +mkdir /etc/service/libfitbit +mkdir /etc/service/libfitbit/log +cp svscan/run /etc/service/libfitbit/run +cp svscan/log-run /etc/service/libfitbit/log/run +chmod +x /etc/service/libfitbit/run /etc/service/libfitbit/log/run diff --git a/svscan/log-run b/svscan/log-run new file mode 100644 index 0000000..52387b9 --- /dev/null +++ b/svscan/log-run @@ -0,0 +1,2 @@ +#!/bin/sh +exec multilog t s1000000 n10 ./main diff --git a/svscan/run b/svscan/run new file mode 100755 index 0000000..cbbe990 --- /dev/null +++ b/svscan/run @@ -0,0 +1,8 @@ +#!/bin/sh + +# prevent burning CPU if there is a problem running the python script +sleep 3 + +echo Starting +export PYTHONPATH='/usr/local/lib/python2.6/dist-packages' +exec python /opt/libfitbit/python/fitbit_client.py 2>&1