benallard-galileo-f0ebc19c748d/.hg_archival.txt0000644000000000000000000000017112767450640017543 0ustar 00000000000000repo: f439ecfadaa724fc313310f8531884bec45c286c node: f0ebc19c748d3d7044a923ab98aab34c5f215815 branch: default tag: 0.5.1 benallard-galileo-f0ebc19c748d/.hgignore0000644000000000000000000000002712767450640016260 0ustar 00000000000000.*~$ .*\.pyc .DS_Store benallard-galileo-f0ebc19c748d/.hgtags0000644000000000000000000000071412767450640015736 0ustar 000000000000000a6745d40756695555b5b5a2589e223be92f5e54 0.1 99517c9fcb0d171ccf99dada33559f2116be3ccc 0.2 07d7730025ab000088520d3257e26bc03c348f7a 0.3 0dc6c42d731c431c83e5afc42f21559f47981c9c 0.3.1 a4b5c744e45d6eb98045e25462580b8e489941d7 0.4 082c494e68ff8d4f7fcc60c9aa05a8e5c33578f5 0.4.1 630b4debcd4fcd80d382e443c8c33c0d1564807e 0.4.2 332f44e94f0acb6169574cf6108a2ba46e30daea 0.4.3 8a506618011ad5666664d144788e9f2b8526166b 0.4.4 1b2fc31c42a13be65326591e12d2b5d25c79ed34 0.5 benallard-galileo-f0ebc19c748d/99-fitbit.rules0000644000000000000000000000031612767450640017252 0ustar 00000000000000# udev rules.d entry for running as a daemon under the "galileo" account. SUBSYSTEM=="usb", ATTR{idVendor}=="2687", ATTR{idProduct}=="fb01", SYMLINK+="fitbit", MODE="0660", OWNER="galileo", GROUP="galileo" benallard-galileo-f0ebc19c748d/CHANGES0000644000000000000000000001734112767450640015457 0ustar 00000000000000galileo 0.5.1 (2016-09-18) -------------------------- This is the first patch release of galileo 0.5, a free utility to securely synchronize fitbit bluetooth trackers with the fitbit web service. This release restores a working default for the synchronisation server name, and makes this setting a configurable parameter. Contributors to this release: Antenore Gatta. Main changes since 0.5: - Make the server name a configuration option (PR#22) - Change the default synchronisation server to a working default (issue#296) galileo 0.5 (2016-07-22) ------------------------ This is the next feature release of galileo, a free utility to securely synchronize fitbit bluetooth trackers with the fitbit web-service. This release is a great improvement over the 0.4 branch. It add support for python 3 as well as rework the communication layer to get rid of timeout as exceptions. Contributor to this release: Dean Giberson, Richard Weait, Chris Wayne, David Vasak, Mike Frysinger, Nenad Jankovic, pozorvlak, Dylan Aïssi and Antenore Gatta. Main changes since 0.4.4: - Add a pair mode (issue#33) - Removal of the Timeout class (issue#43) - Get a UI abstraction layer (issue#31) - Keep a rolling log of the last communication to help debugging (issue#67) - Support sending logging output to syslog (issue#134) - Catch HTTPError when syncing (issue#147) - Improve Charge HR support (issue#148) - Improve Discovery process (issue#231) - Add Support for python 3.4 (issue#116) - Add support for newer dongles (issue#236) - Update the server name (issue#277) galileo 0.4.4 (2015-05-31) -------------------------- This is the fourth patch release of galileo 0.4, a free utility to securely synchronize fitbit bluetooth trackers with the fitbit web service. This release adds support for older python version (2.6), fixes an issue with the BackOffException, properly cleans up the USB connection when done as well as improves support for the Charge HR tracker. Contributor to this release: Slobodan Miskovic, Noel Jackson and Nenad Jankovic. Main changes since 0.4.3: - Add support for python 2.6 - Fix handling of BackOffException (issue#140) - Reset the USB device when we're done with it in order to prevent a "Device Busy" on subsequent tries (issue#142 and a few more ...) - Better adjust timeouts for the Charge HR tracker. - Discard the BackOff Exception when a payload is transmitted. galileo 0.4.3 (2014-11-27) -------------------------- This is the third patch release of galileo 0.4, a free utility to securely synchronize fitbit bluetooth trackers with the fitbit web service. This release adds support for the new Charge tracker. Main changes since 0.4.2: - Increase a timeout to support the Charge tracker (issue#123) - Exclude the `tests` packages when installing. galileo 0.4.2 (2014-10-15) -------------------------- This is the second patch release of galileo 0.4, a free utility to securely synchronize fitbit bluetooth trackers with the fitbit web service. This release fixes a couple of API changes in the dependent libraries since the release of the previous version. Main changes since 0.4.1: - Correctly recognize TimeoutError from libusb0 (issue#82) - Fix TypeError with newer version of PyUSB (issue#36, issue#77, and a few more ...) - Fix error when displaying the reason for a Connection Error (issue#118). galileo 0.4.1 (2014-06-22) -------------------------- This is the first patch release of galileo 0.4, a free utility to securely synchronize fitbit bluetooth trackers with the fitbit web-service. This release fixes a number of issues reported with the release of galileo 0.4. All users of galileo 0.4 are encouraged to upgrade. Main changes since 0.4: - Fix a traceback in debug message (part of issue#51) - Fix issue when the dongle doesn't reports its version (issue#53) - Fix issue when ConnectionError happens during sync (issue#54) - Try again a write operation in case of IOError (issue#61) - Be more strict during discovery (issue#66) - Handle issue when USB backend does not implement non-mandatory methods (issue#75) - Recognize one more kind of TimeoutError (issue#82) galileo 0.4 (2014-03-31) ------------------------ This is the next feature release of galileo, a free utility to securely synchronize fitbit bluetooth trackers with the fitbit web-service. This release introduce a `daemon` mode that synchronize periodically the available trackers, making it easier for integration as a service. As well as enhance the configurability by introducing more options, and also allowing them to be read from configuration files. Man pages have also been added. Contributors to this release: Stuart Hickinbottom, and Alexander Voronin. Main changes since 0.3.1: - Manual pages added for galileo(1) and galileorc(5) (issue#38, PR #12) - Add compatibility with dongles 1.6 (issue#45) - Add a lots of tests (issue#32) - Add a `daemon` mode (issue#30) - Detect when Fitbit server is in 'maintenance mode' (issue#29) - Read the configuration from files (issue#18, PR #5) - Validation of the CRC value of the dumps (issue#15) - Add --include and --exclude to control which trackers to synchronize (PR #4) - Add a --no-upload command line parameter to prevent the uploading of the dump to the server - Major code reorganisation. galileo 0.3.1 (2014-02-01) -------------------------- This is the first patch release of galileo 0.3, a free utility to securely synchronize fitbit bluetooth trackers with the fitbit web-service. This release change the communication protocol used to communicate between galileo and the fitbit web-service from a plain-text one (HTTP) to one that uses state-of-the-art encryption methods (HTTPS), preventing the data extracted from the tracker of being intercepted and read on its way to the fitbit servers. Main changes since 0.3: - Switch the communication protocol from HTTP to HTTPS galileo 0.3 (2014-01-27) ------------------------ This is the third version of galileo, a free utility to synchronise fitbit bluetooth trackers with the fitbit service. This release greatly enhance the user friendliness by adding support for command line switches to control the various aspects of the synchronisation. As well as improves the code quality. New contributors to this release: Stuart Hickinbottom. Main changes since 0.2: - Improve error reporting when insufficient permissions are set on the usb device (PR #3, issue#10) - Add --no-dump to prevent writing a backup of the dump to disc (issue#19). - Only sync the trackers that have not been sync'd for some time. Use --force to always sync all the discovered trackers (PR #2, issue#13). - Warn when the signal from the tracker is too weak (issue#12). - Add command-line switches to control verbosity (PR #1, issue#9). - Register package to PyPi, and allow installation via pip. - Improve detection of the end of the dump (issue#2). - Unify the timeout values. - Code cleanup. galileo 0.2 (2013-12-30) ------------------------ This was the second version of galileo, a free utility to synchronise fitbit trackers with the fitbit server. This version fixes an issue when the dump from the tracker was not being accepted by the server. Main changes since 0.1: - Unescape some bits before transmitting to the server, this solves an issue with the fitbit data not being accepted by the server (issue#1). - Add a udev rules files to allow the utility to run as a non-privileged user. - Add a diff.py script to analyse difference in dumps. - Also dump the response from the server in the dump file. - Code cleanup galileo 0.1 (2013-11-24) ------------------------ This was the first release of galileo, a free utility to synchronise fitbit trackers with the fitbit servers. Main features: - synchronization of any bluetooth based fitbit tracker with the fitbit server. - backup of dumps on disc in the ~/.galileo// directory. benallard-galileo-f0ebc19c748d/COPYING0000644000000000000000000001674312767450640015524 0ustar 00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. benallard-galileo-f0ebc19c748d/README.txt0000644000000000000000000001355112767450640016161 0ustar 00000000000000Galileo ======= :author: Benoît Allard :version: 0.5.1 :license: LGPLv3+ :bug tracker: https://bitbucket.org/benallard/galileo/issues :mailing list: galileo@freelists.org (subscribe_, archive_) :build status: |droneio_badge|_ .. _subscribe: mailto:galileo-request@freelists.org?subject=subscribe .. _archive: http://freelists.org/archive/galileo/ .. |droneio_badge| image:: https://drone.io/bitbucket.org/benallard/galileo/status.png .. _droneio_badge: https://drone.io/bitbucket.org/benallard/galileo Introduction ------------ Galileo is a Python utility to securely synchronize a Fitbit device with the Fitbit web service. It allows you to browse your data on their website, and compatible applications. All Bluetooth-based trackers are supported. Those are: - Fitbit One - Fitbit Zip - Fitbit Flex - Fitbit Force - Fitbit Charge - Fitbit Charge HR .. note:: The newer Trackers: Alta, Surge, and Blaze are **not supported** due to a change in the network communication protocol. Help with dumps is welcome ! .. note:: The Fitbit Ultra tracker is **not supported** as it communicates using the ANT protocol. To synchronize it, please use libfitbit_. This utility is mainly targeted at Linux because Fitbit does not provide any Linux-compatible software, but as Python is cross-platform and the libraries used are available on a broad variety of platforms, it should not be too difficult to port it to other platforms. .. _libfitbit: https://github.com/openyou/libfitbit Main features ------------- - Synchronize your fitbit tracker with the fitbit server using the provided dongle. - Securely communicate (using HTTPS) with the fitbit server. - Save all your dumps locally for possible later analyse. Installation ------------ The easy way ~~~~~~~~~~~~ .. warning:: If you want to run the utility as a non-root user, you will have to install the udev rules manually (See `The more complicated way`_, or follow the instructions given when it fails). :: $ pip install galileo $ galileo .. note:: If you don't want to install this utility system-wide, you may want to install it inside a virtualenv_, the behaviour will not be affected. .. _virtualenv: http://www.virtualenv.org Distribution packages ~~~~~~~~~~~~~~~~~~~~~ The following Linux distributions have packages available for installation: **Arch**: The utility is available from AUR_. You can install it using the yaourt_ package manager: ``yaourt -S galileo``. **Fedora**: The utility is packaged in a `COPR repo`_. Download the relevant repo for your version of Fedora, and then ``yum install galileo``. **Gentoo**: The utility is packaged as ``app-misc/galileo`` within the `squeezebox `_ overlay. See https://wiki.gentoo.org/wiki/Layman for details of how to use Gentoo overlays. **Debian**: galileo is now officially part of the sid_ distribution. **Ubuntu**: The utility is available over the ppa ``ppa:cwayne18/fitbit``. Use the following commands to install it and start the daemon:: sudo add-apt-repository ppa:cwayne18/fitbit sudo apt-get update && sudo apt-get install galileo start galileo .. note: This ppa has no support for newer Ubuntu releases. There are reports that the Debian package (see above) can be installed there though. .. _AUR: https://aur.archlinux.org/packages/galileo/ .. _yaourt: https://wiki.archlinux.org/index.php/yaourt .. _`COPR repo`: https://copr.fedoraproject.org/coprs/stbenjam/galileo/ .. _sid: https://packages.debian.org/sid/main/galileo The more complicated way ~~~~~~~~~~~~~~~~~~~~~~~~ First, you need to clone this repository locally, and install the required dependencies: **pyusb**: Need at least a 1.0 version, 0.4 and earlier are not compatible. Please use a tagged release as development version might contains bug or interface breakage. **requests**: Newer versions (2.x) preferred, although older should also work. You should copy the file ``99-fitbit.rules`` to the directory ``/etc/udev/rules.d`` in order to be able to run the utility as a non-root user. Don't forget to: - restart the udev service: ``sudo service udev restart`` - unplug and re-insert the dongle to activate the new rule. Then simply run the ``run`` script located at the root of this repository. If your system uses systemd then there is an example unit file in the ``contrib`` directory that you may wish to customize. Documentation ------------- For the moment, this README (and the ``--help`` command line option) is the main documentation we have. The wiki_ is meant to gather technical information about the project like the communication protocol, or the format of the dump. Once this information reached a suffficient level of maturation, the goal is to integrate it into the project documentation. So head-on there, and start sharing your findings ! Manual pages for the galileo_\(1) utility and the galileorc_\(5) configuration file are provided within the ``doc`` directory. .. _wiki: https://bitbucket.org/benallard/galileo/wiki .. _galileo: https://pythonhosted.org/galileo/galileo.1.html .. _galileorc: https://pythonhosted.org/galileo/galileorc.5.html Acknowledgements ---------------- Special thanks to the folks present @ the `issue 46`_ of libfitbit. Especially to `sansneural `_ for https://docs.google.com/file/d/0BwJmJQV9_KRcSE0ySGxkbG1PbVE/edit and `Ingo Lütkebohle`_ for http://pastebin.com/KZS2inpq. .. _`issue 46`: https://github.com/openyou/libfitbit/issues/46 .. _`Ingo Lütkebohle`: https://github.com/iluetkeb Disclaimer ---------- Fitbit is a registered trademark and service mark of Fitbit, Inc. galileo is designed for use with the Fitbit platform. This product is not put out by Fitbit, and Fitbit does not service or warrant the functionality of this product. benallard-galileo-f0ebc19c748d/analysedir.py0000755000000000000000000000174712767450640017177 0ustar 00000000000000#!/usr/bin/env python import os import re from analysedump import readdump from analysedump import analyse as longanalyse TYPES = {0xF4: 'Zip', 0x26: 'One', 0x28: 'Flex'} def analyse(filename): s = [] with open(filename, 'rt') as dump: data, response = readdump(dump) s.append(TYPES[data[0]]) s.append(str(len(data))) s.append(str(len(response))) print ' '.join(s) try: longanalyse(data) except: print filename raise def main(dirname): for root, dirs, files in os.walk(dirname): for dump in sorted(files): if not re.match('dump-\d{10}.txt', dump): continue filename = os.path.join(root, dump) print dump analyse(filename) if __name__ == "__main__": import sys try: os.path.exists(sys.argv[1]) filename = sys.argv[1] print "Single file mode: ", filename analyse(filename) except: main(sys.argv[1]) benallard-galileo-f0ebc19c748d/analysedump.py0000755000000000000000000001407412767450640017363 0ustar 00000000000000#!/usr/bin/env python import os import sys import time import base64 def readlog(f): """ input is from f in the format of lines of long string starting with a tab ('\t') representing the hexadcimal representation of the data (megadump)""" d = [] for line in f: if line[0] != '\t': if d: return d continue line = line.strip() for i in range(0, len(line), 2): d.append(int(line[i:i + 2], 16)) return d def readdump(f): """ imput is from ./galileo.py """ d = [] r = [] current = d for line in f: if line.strip() == '': current = r continue current.extend(int(x, 16) for x in line.strip().split()) return d, r def a2s(array): """ array of int to string """ return ''.join(chr(c) for c in array) def a2x(array): """ array of int to hex representation """ return ' '.join("%02X" % i for i in array) def a2lsbi(array): """ array to int (LSB first) """ integer = 0 for i in range(len(array) - 1, -1, -1): integer *= 256 integer += array[i] # print a2x(array), hex(integer) return integer def a2msbi(array): """ array to int (MSB first) """ integer = 0 for i in range(len(array)): integer *= 256 integer += array[i] return integer def header(data): index = 40 walkStrideLen = a2lsbi(data[index:index + 2]) index += 2 runStrideLen = a2lsbi(data[index:index + 2]) index += 2 print "Stride lengths: %dmm, %dmm" % (walkStrideLen, runStrideLen) print a2x(data[index:index + 4]) index += 4 # empirical value index += 12 if index >= len(data): return # Greetings print "Greetings: '%s'" % a2s(data[index:index + 10]) index += 10 # Cheering print "Cheering" for i in range(3): print "'%s'" % a2s(data[index:index + 10]) index += 10 def first_field(rec_len): def unknown(data): assert data[:3] == [END, END, 0xdd], a2x(data[:3]) index = 3 while index < len(data) - 1: tstamp = a2lsbi(data[index:index + 4]) print time.strftime("%x %X", time.localtime(tstamp)), hex(tstamp) index += 4 print "\t%s" % a2x(data[index:index + rec_len]) index += rec_len return unknown def minutely(rec_len): """ this analyses the minute-by-minute information """ def minutes(data): assert data[:3] == [END, END, 0xdd], a2x(data[:3]) index = 3 tstamp = 0 while index < len(data) - 1: if not (data[index] & 0x80): tstamp = a2msbi(data[index:index + 4]) index += 4 else: print time.strftime("%x %X", time.localtime(tstamp)), a2x(data[index:index + rec_len]) tstamp += 60 index += rec_len return minutes def stairs(data): """ Looks like stairs informations are put here """ assert data[:3] == [END, END, 0xdd], a2x(data) index = 3 index = 3 tstamp = 0 while index < len(data) - 1: if not (data[index] & 0x80): tstamp = a2msbi(data[index:index + 4]) index += 4 else: if data[index] != 0x80: #print a2x([array[index]]) index += 1 print time.strftime("%x %X", time.localtime(tstamp)), a2x(data[index:index + 2]) tstamp += 60 index += 2 def daily(data): if len(data) == 2: assert data == [END, END], a2x(data) return assert data[:3] == [END, END, 0xdd], a2x(data[:3]) index = 3 while index < len(data) - 1: tstamp = a2lsbi(data[index:index + 4]) index += 4 print time.strftime("%x %X", time.localtime(tstamp)), a2x(data[index:index + 12]) index += 12 def footer(data): assert len(data) == 9, data print 'Dump size: %d bytes' % a2lsbi(data[5:7]) print a2x(data) ESC = 0xdb END = 0xc0 ESC_ = {0xdc: END, 0xdd: ESC} def unSLIP(data): """ This remove SLIP escaping and yield the parts The magic are: The first part doesn't ends with 0xC0 there are empty parts >>> list(unSLIP([1, 2, 0xc0, 5, 4, 0xc0, 0xc0, 8, 4])) [[1, 2], [192, 5, 4, 192], [192, 8, 4]] >>> list(unSLIP([12, 0xc0, 0, 0, 0xc0, 1, 2, 0xc0, 8, 9])) """ first = True part = [] escape = False for c in data: # print "%x" % c if not escape: if c == ESC and part and part[0] == END: escape = True else: part.append(c) if c == END: if len(part) != 1: if first or (part[0] != END): yield part[:-1] part = [part[-1]] first = False else: yield part part = [] else: part.append(ESC_[c]) escape = False yield part def analyse(data): def onscreen(data): print a2x(data) def skip(data): pass display = [onscreen] * 20 analyses_ZIP = [header, first_field(9), minutely(3), daily, footer] analyses_ONE = [header, first_field(11), minutely(4), stairs, daily, skip, onscreen, skip, footer] analyses = { 0x26: analyses_ONE, 0xF4: analyses_ZIP, }.get(data[0], display) for i, part in enumerate(unSLIP(data)): f = analyses[i] print "%s (%d): %d bytes" % (f.__name__, i, len(part)) f(part) def analysedump(dump_dir, index): for root, dirs, files in os.walk(dir): file = sorted(files)[idx] print "Analysing %s" % file with open(os.path.join(root, file)) as f: dump, response = readdump(f) analyse(dump) if __name__ == "__main__": if len(sys.argv) == 1: dump, response = readdump(sys.stdin) analyse(dump) else: dir = sys.argv[1] idx = -1 if len(sys.argv) > 2: idx = int(sys.argv[2]) analysedump(dir, idx) benallard-galileo-f0ebc19c748d/contrib/README.txt0000644000000000000000000000171212767450640017615 0ustar 00000000000000Miscellaneous Contributions =========================== This directory contains a number of additional files that may be of interest. These are not necessary to use Galileo, but can help those packaging or customising the utility. galileo.service --------------- This is a **systemd unit** file that can be used to start and stop the Galileo utility when running in 'daemon mode'. This was originally created for the Gentoo package and will likely need customization for other distributions. In particular, the user and group that the daemon runs as, as well as the location of the configuration file, may need to be changed. This service unit file should be installed into the lib/systemd/system directory. galileo.upstart --------------- This is an **upstart** file that can be used to start and stop the galileo utility when running in 'daemon mode'. This was originally created for the Ubuntu distribution, and might need some customization to run elsewhere. benallard-galileo-f0ebc19c748d/contrib/galileo.service0000644000000000000000000000063312767450640021116 0ustar 00000000000000# Unit file for Galileo # # See systemd.service(5) for further information. [Unit] Description=Synchronisation utility for Bluetooth LE-based Fitbit trackers Documentation=man:galileo(1) man:galileorc(5) Documentation=https://bitbucket.org/benallard/galileo After=network.target [Service] User=galileo Group=galileo ExecStart=/usr/bin/galileo --config /etc/galileorc daemon [Install] WantedBy=network.target benallard-galileo-f0ebc19c748d/contrib/galileo.upstart0000644000000000000000000000022712767450640021157 0ustar 00000000000000description "Galileo to sync fitbit devices" start on started dbus stop on desktop-end respawn respawn limit unlimited exec /usr/bin/galileo daemon benallard-galileo-f0ebc19c748d/diff.py0000755000000000000000000000524112767450640015745 0ustar 00000000000000#!/usr/bin/env python import os from analysedump import readdump def s2a(s): return [ord(c) for c in s] def LCS(X, Y): m = len(X) n = len(Y) C = [[0] * (n + 1) for i in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if X[i - 1] == Y[j - 1]: C[i][j] = C[i - 1][j - 1] + 1 else: C[i][j] = max(C[i][j - 1], C[i - 1][j]) return C SYMBOLS = {0: ' ', -1: '-', 1: '+'} def _diff(C, X, Y, i, j): while (i, j) != (0, 0): if i > 0 and j > 0 and X[i - 1] == Y[j - 1]: yield (0, X[i - 1]) i -= 1 j -= 1 elif j > 0 and ((i == 0) or (C[i][j - 1] >= C[i - 1][j])): yield (1, Y[j - 1]) j -= 1 elif i > 0 and ((j == 0) or C[i][j - 1] < C[i - 1][j]): yield (-1, X[i - 1]) i -= 1 else: assert False, ' '.join(str(c) for c in [i, j, C[i][j - 1], C[i - 1][j]]) def diff(X, Y, maxL=20): start = 0 oldmode = 0 s = [] while start < len(X) and start < len(Y) and X[start] == Y[start]: if len(s) == maxL: print SYMBOLS[oldmode], ' '.join('%02X' % i for i in s) s = [] s.append(X[start]) start += 1 print SYMBOLS[oldmode], ' '.join('%02X' % i for i in s) s = [] X = X[start:] Y = Y[start:] C = LCS(X, Y) for chunk in reversed(list(_diff(C, X, Y, len(X), len(Y)))): if s and ((len(s) == maxL) or chunk[0] != oldmode): print SYMBOLS[oldmode], ' '.join('%02X' % i for i in s) s = [] s.append(chunk[1]) oldmode = chunk[0] # Print the last one print SYMBOLS[oldmode], ' '.join('%02X' % i for i in s) return oldmode * len(s) def dumpdiff(dump1, dump2): with open(dump1) as f: data1, resp1 = readdump(f) with open(dump2) as f: data2, resp2 = readdump(f) diff(data1, data2) print '-' * 20 diff(resp1, resp2) def diffdir(basedir): for root, dirs, files in os.walk(basedir): files = sorted(files) for i in range(0, len(files) - 1): print files[i] print files[i + 1] try: dumpdiff(os.path.join(root, files[i]), os.path.join(root, files[i + 1])) except RuntimeError: print 'Trouble with %s and %s' % (files[i], files[i + 1]) print '------------------------------------' if __name__ == "__main__": import sys if len(sys.argv) == 2: # one param diffdir(sys.argv[1]) elif len(sys.argv) == 3: # Two params dumpdiff(sys.argv[1], sys.argv[2]) benallard-galileo-f0ebc19c748d/doc/galileo.10000644000000000000000000001507112767450640016725 0ustar 00000000000000.\" galileo python command-line utility manual page. .\" .\" View this file before installing it with: .\" groff -man -Tascii galileo.1 .\" or .\" man ./galileo.1 .TH galileo 1 "June 2014" 0.5.1 "User Commands" .SH NAME galileo \- synchronize Fitbit devices .SH SYNOPSIS .B galileo .RB [ "\-h" ] .RB [ "\-c \fIFILENAME\fR" ] .RB [ "\-\-dump\-dir \fIDIR\fR" ] .RB [ "\-\-daemon\-period \fIPERIOD\fR" ] .RB [ "\-I \fIID\fR" "[ \fIID \.\.\.\fR ] ]" .RB [ "\-X \fIID\fR" "[ \fIID \.\.\.\fR ] ]" .RB [ "\-v" | "\-d" | "\-q" ] .RB [ "\-\-force" | "\-\-no\-force" ] .RB [ "\-\-dump" | "\-\-no\-dump" ] .RB [ "\-\-upload" | "\-\-no\-upload" ] .RB [ "\-\-https\-only" | "\-\-no\-https\-only" ] .RB [ "\-s \fISERVERNAME\fR" ] .RB [ "\-\-log\-size \fISIZE\fR" ] .RB [ "\-\-syslog" | "\-\-no\-syslog" ] .RB [ "sync" | "daemon" | "version" ] .SH DESCRIPTION Synchronize Fitbit wearable fitness tracker devices with the Fitbit web service. Visit < .B https://www.fitbit.com >, or use a Fitbit-compatible app in order to browse your data. .SH MODES .TP .B sync Perform the synchronization of all found trackers, then exit. This is the default mode if none is specified. .TP .B daemon Periodically perform synchronization of all found trackers. .B galileo will periodically perform synchronization until the daemon is killed. The period can be controlled via the .B \-\-daemon\-period option. .TP .B version Display the .B galileo version and exit. .TP .B interactive This spawn an interactive shell to allow sending arbitrary commands to the dongle and the tracker. This is meant to allow experimenting with new commands, or different command orders. To be used by experts only. .TP .B pair This mode, in an experimental state, allow you to link your tracker with your Fitbit online account. This is needed before being able to use any new tracker. In order to use this mode, you need an account in the Fitbit online platform, and a tracker. The parameters for this mode are taken from the .B hardcoded-ui section of the .BR galileorc (5) file. .SH OPTIONS .TP .BR \-h ", " \-\-help show command-line usage and exit. .TP .BR "\-c \fIRCCONFIGNAME\fR" ", " "\-\-config \fIRCCONFIGNAME\fR" use \fIRCCONFIGNAME\fR as extra configuration file in order to allow overriding of settings. .P The remaining options are first read from configuration file, and can be overridden by using command line switches. For this reason, positive and negative versions are available (\fB\-\-foo\fR and \fB\-\-no\-foo\fR). Please see .BR galileorc (5) for more information about the configuration files. .SS Logging options: .TP .BR \-v ", " \-\-verbose display general information on progress during synchronization. .TP .BR \-d ", " \-\-debug as \fB\-\-verbose\fR, but also shows internal activity useful for diagnosing problems. .TP .BR \-q ", " \-\-quiet show no output except for errors and a summary. This is the default if no other logging options are specified. .TP .BR \-\-syslog send logging output to the syslog facility. Due to the rate-limiting of some syslog servers, this option might not work in combination with the debug log level. .TP .BR \-\-no\-syslog send logging output to stderr. .SS Synchronization control options: .TP \fB\-I\fR \fIID\fR [\fIID\fR ...], \ \fB\-\-include\fR \fIID\fR [\fIID\fR ...] list of tracker IDs to synchronize (if not set, all found trackers are synchronized). .TP \fB\-X\fR \fIID\fR [\fIID\fR ...], \ \fB\-\-exclude\fR \fIID\fR [\fIID\fR ...] list of tracker IDs to avoid synchronizing (no trackers are excluded by default). .TP .B \-\-force a tracker will not be synchronized with the Fitbit web service if it reports that it was recently synchronized. This option overrides that behavior. .TP .B \-\-no\-force if the configuration file includes the \fBforce\-sync\fR option to always force synchronization, this option will restore the default behaviour. .TP .BI \-\-daemon\-period " PERIOD" set the time to wait between synchronizations when running in \fBdaemon\fR mode. The period is specified in milliseconds and defaults to 15000 (15 seconds). .SS Tracker data saving options: .TP .B \-\-dump save a copy of the tracker data. Tracker data is stored under a tracker-specific subdirectory of a directory that is set using the \fB\-\-dump\-dir\fR option. This is the default behavior. .TP .B \-\-no\-dump disables the saving of tracker data. .TP .BI \-\-dump\-dir " DIR" the directory used to store the tracker dumps (defaults to \fB~/.galileo\fR). .SS Data transfer options: .TP .B \-\-upload synchronize tracker data with the Fitbit web service. This is the default. .TP .B \-\-no\-upload prevent the uploading of tracker data to the Fitbit web service. Data is not deleted from trackers until it is acknowledged by the fitbit server so this will not result in data loss. .TP .B \-\-https\-only data sent to the Fitbit web service will be transferred via a secure connection using HTTPS. This is the default. .TP .B \-\-no\-https\-only if HTTPS connection is not possible, this will allow the fallback to HTTP. This should only be required if problems with encryption libraries prevent data transfer without this option. .TP .BR "\-s \fISERVERNAME\fR" ", " "\-\-fitbit\-server \fISERVERNAME\fR" the server to connect to when performing the synchronization (default to \fBapi.fitbit.com\fR). .TP .BI \-\-log\-size " SIZE" indicate the amount of communication that should be displayed in case of errors. Galileo will keep in memory the last \fISIZE\fR communications to help debugging if an error happen. This is particularly useful in case of hard-to-reproduce issues, where it is too late to collect debug information. Default to 10. Set to 0 to disable this functionality. .SH REQUIREMENTS An original Fitbit Bluetooth-LE USB synchronization dongle is required. .PP The Fitbit tracker must already be registered to your Fitbit account (see the BUGS section). .SH FILES .TP .IR /etc/galileo/config ", " $XDG_CONFIG_HOME/galileo/config ", " ~/.galileorc The configuration files used for default settings. See .BR galileorc (5) for further details about those files .SH SEE ALSO .TP <\fBhttp://www.fitbit.com\fR> The Fitbit web service where synchronized tracker data may be viewed. .TP <\fBhttps://bitbucket.org/benallard/galileo\fR> The \fBgalileo\fR homepage where additional information is available. .TP .BR galileorc (5) The format of the configuration file providing default settings. .SH AUTHOR Written and maintained by Benoît Allard, with contributions from other authors. .SH BUGS There are no current facilities to make use of the data stored with the \fB\-\-dump\fR command. .PP Please report additional bugs to <\fBhttps://bitbucket.org/benallard/galileo/issues\fR> benallard-galileo-f0ebc19c748d/doc/galileorc.50000644000000000000000000001145612767450640017261 0ustar 00000000000000.\" galileorc galileo configuration file manual page. .\" .\" View this file before installing it with: .\" groff -man -Tascii galileorc.5 .\" or .\" man ./galileorc.5 .TH galileorc 5 "June 2014" 0.5.1 "File Formats Manual" .SH NAME galileorc \- configuration files for the galileo Fitbit synchronization utility .SH DESCRIPTION The .B galileorc file is used to provide default settings to the .BR galileo (1) utility. Any settings that would normally be passed as command\-line arguments to galileo can, instead, be present in this configuration file to prevent having to repeat them again and again. .PP Settings provided in the configuration files can be overridden by run\-time command\-line switches. See .BR galileo (1) . .SH FILES The following files will be read if present. Later one override previous settings and settings provided on the command-line override settings defined in configuration files. .IP \(bu .I /etc/galileo/config .IP \(bu .I $XDG_CONFIG_HOME/galileo/config (The \fBXDG_CONFIG_HOME\fR environment variable default to \fI~/.config\fR if not defined) .IP \(bu .I ~/.galileorc .IP \(bu any file specified with the \fB-c\fR command\-line switch .SH SYNTAX The settings file is defined in \fIYAML\fR format. Blank lines and comments (from the first hash character \(aq#\(aq to the end of the line) are ignored. .PP The configuration file is parsed as a dictionary of settings, which means that each setting is defined using a keyword followed by a colon character. For single\-value settings (the majority), the value follows the colon, for example: .PP .nf do-upload: true .fi .PP For settings of type \fIlist\fR (such as the tracker ID inclusion and exclusion lists), the values appear with an indentation on subsequent lines and prefixed with a dash, for example: .PP .nf include: - '123456789ABC' - '9876543210AB' .fi .SH SETTINGS The following settings can be added to the configuration files \- not all options have to be specified; any that are not mentioned will leave the defaults in effect. See .BR galileo (1) for details about the default values. .TP .B logging controls the amount of progress output. Can be \fBverbose\fR to display progress during synchronization, \fBdebug\fR for more detailed information useful for diagnosing problems, or \fBquiet\fR to display only a warning and error messages. .TP .B syslog setting this to \fBtrue\fR will send all logging output to the syslog facility. Due to the rate-limiting of some syslog servers, this option might not work in combination with the debug log level. .TP .B include the list of tracker IDs to synchronize. If this is specified then only trackers from this list will be synchronised. .TP .B exclude the list of tracker IDs not to synchronize. .TP .B force-sync setting this to \fBtrue\fR causes trackers to be synchronized even if they report that they already have been synchronized recently. .TP .B daemon-period this defines, in milliseconds, the period at which a synchronisation attempt will be performed when galileo is run in \fBdaemon\fR mode. .TP .B keep-dumps setting this to \fBtrue\fR causes galileo to save the data retrieved from trackers to the directory specified in \fBdump-dir\fR. .TP .B dump-dir the directory used for saving tracker data if the \fBkeep-dumps\fR option is set. .TP .B do-upload setting this to \fBfalse\fR will prevent galileo from sending tracker data to the Fitbit web service. .TP .B fitbit-server this setting allow to specify the name of the server to connect to when performing the synchronization. .TP .B https-only setting this to \fBfalse\fR will allow galileo to fallback to unencrypted HTTP if HTTPS fails for sending tracker data to the Fitbit web service. .TP .B hardcoded-ui This is a structured section that includes the answers needed during the pairing/firmware update process. .SH EXAMPLE The following is an example configuration file: .PP .nf daemon-period: 60000 keep-dumps: false do-upload: true dump-dir: ~/.galileo-tracker-data logging: verbose force-sync: false https-only: false include: - '123456789ABC' - '9876543210AB' exclude: - 'AABBCCDDEEFF' - '881144BB1234' .fi .SH SEE ALSO .TP <\fBhttp://www.yaml.org\fR> The official YAML homepage, with more background information on the YAML file format. .TP .BR galileo (1) The \fBgalileo\fR utility which uses these configuration files for default settings. .TP <\fBhttps://bitbucket.org/benallard/galileo\fR> The \fBgalileo\fR homepage where additional information is available. .SH AUTHOR Written and maintained by Benoît Allard, with contributions from other authors. .SH BUGS Tracker IDs which consist of only numbers must be surrounded with single quotes (as in the \fIEXAMPLE\fR section above). It's probably a good idea to always quote tracker IDs to avoid possible confusion. .PP Please report additional bugs to <\fBhttps://bitbucket.org/benallard/galileo/issues\fR>. benallard-galileo-f0ebc19c748d/galileo/__init__.py0000644000000000000000000000015312767450640020202 0ustar 00000000000000"""\ galileo.py Utility to synchronize a fitbit tracker with the fitbit server. """ __version__ = '0.5.1' benallard-galileo-f0ebc19c748d/galileo/config.py0000644000000000000000000003453012767450640017716 0ustar 00000000000000import os import argparse import logging logger = logging.getLogger(__name__) try: import yaml except ImportError: from . import parser as yaml from .utils import a2x class ConfigError(Exception): pass class ConfigFileError(ConfigError): def __init__(self, filename, paramName, msg=""): self.filename = filename self.paramName = paramName self.msg = msg def __str__(self): s = "Error parsing parameter '%s' in file '%s'" % ( self.paramName, self.filename) if self.msg: s += ": %s" % self.msg return s class Parameter(object): def __init__(self, varName, name, paramName, default, paramOnly, helpText): # The name of the variable that will be used self.varName = varName # the internal name self.name = name # Tuple about the parameter names (short, long) self.paramName = paramName # the default Value self.default = default self.helpText = helpText self.paramOnly = paramOnly def toArgParse(self, parser): """ Add the parameter to the 'argparse' parser given in parameter """ raise NotImplementedError def fromArgs(self, args, optdict): """ Take the value from the args parameter (from 'argparse'), and fill it in the dict """ val = getattr(args, self.name) if val: optdict[self.varName] = val def fromFile(self, filedict, optdict): """ Take the value from the filedict parameter and fill it in the dict :returns: False if something went wrong """ if self.paramOnly: return True if self.name in filedict: optdict[self.varName] = filedict[self.name] return True class StrParameter(Parameter): def toArgParse(self, parser): parser.add_argument(*self.paramName, dest=self.name, help=self.helpText + " (default to %s)" % self.default) class IntParameter(Parameter): def toArgParse(self, parser): parser.add_argument(*self.paramName, dest=self.name, type=int, help=self.helpText + " (default to %s)" % self.default) class BoolParameter(Parameter): def toArgParse(self, parser): if self.paramOnly: parser.add_argument(*self.paramName, action={True: "store_false", False: "store_true"}[self.defaultVal], dest=self.name, help=self.helpText) else: # We need the True and False version assert len(self.paramName) == 1, len(self.paramName) self.paramName = self.paramName[0] if self.paramName.startswith('--'): self.paramName = self.paramName[2:] group = parser.add_argument_group( description="whether or not to "+self.helpText) mut_ex_group = group.add_mutually_exclusive_group() _help = {} if self.default: _help['help'] = "DEFAULT" mut_ex_group.add_argument("--%s" % self.paramName, action="store_true", dest=self.name, **_help) _help = {} if not self.default: _help['help'] = "DEFAULT" mut_ex_group.add_argument("--no-%s" % self.paramName, action="store_true", dest="no_%s" % self.name, **_help) def fromArgs(self, args, optdict): if self.paramOnly: optdict[self.varName] = getattr(args, self.name) else: if getattr(args, "no_"+self.name): optdict[self.varName] = False elif getattr(args, self.name): optdict[self.varName] = True class SetParameter(Parameter): def toArgParse(self, parser): parser.add_argument(*self.paramName, nargs="+", metavar="ID", dest=self.name, help=self.helpText) def fromArgs(self, args, optdict): # Now make sure the list of trackers is all in upper-case to # make comparisons easier later. values = [x.upper() for x in (getattr(args, self.name) or [])] if optdict[self.varName] is None and values: optdict[self.varName] = set() if values: optdict[self.varName].update(values) def fromFile(self, filedict, optdict): if self.paramOnly: return True if self.name in filedict: values = [x.upper() for x in filedict[self.name]] if optdict[self.varName] is None and values: optdict[self.varName] = set() optdict[self.varName].update(values) return True class LogLevelParameter(Parameter): """ A class extra for setting the LogLevel """ def __init__(self): Parameter.__init__(self, 'logLevel', 'logging', (), logging.WARNING, False, "logging Verbosity") self.__logLevelMap = {'quiet': logging.WARNING, 'verbose': logging.INFO, 'debug': logging.DEBUG} self.__logLevelMapReverse = {} for key, value in self.__logLevelMap.items(): self.__logLevelMapReverse[value] = key self.default = logging.WARNING def toArgParse(self, parser): verbosity_arggroup = parser.add_argument_group(title=self.helpText) verbosity_arggroup2 = verbosity_arggroup.add_mutually_exclusive_group() verbosity_arggroup2.add_argument("-v", "--verbose", action="store_true", help="display synchronization progress") verbosity_arggroup2.add_argument("-d", "--debug", action="store_true", help="show internal activity (implies verbose)") verbosity_arggroup2.add_argument("-q", "--quiet", action="store_true", help="only show errors and summary (default)") def fromArgs(self, args, optdict): value = None if args.verbose: value = self.__logLevelMap['verbose'] elif args.debug: value = self.__logLevelMap['debug'] elif args.quiet: value = self.__logLevelMap['quiet'] if value is not None: optdict[self.varName] = value def fromFile(self, filedict, optdict): if self.paramOnly: return if self.name in filedict: loglevel = filedict[self.name].lower() try: optdict[self.varName] = self.__logLevelMap[loglevel] except KeyError: return False return True class Argument(StrParameter): """ Extra class for the positional argument """ def __init__(self): StrParameter.__init__(self, 'mode', 'mode', ('mode',), 'sync', True, 'The mode to run') def toArgParse(self, parser): parser.add_argument(*self.paramName, nargs='?', choices=['version', 'sync', 'daemon', 'pair', 'firmware', 'interactive'], help=self.helpText + " (default to %s)" % self.default) class HardCodedUIConfig(Parameter): """\ A Config parameter for the config of the HardCodedUI class """ def __init__(self): self.name = 'hardcoded-ui' self.varName = self.name.replace('-', '_') self.default = {} def toArgParse(self, parser): """ no-op """ def fromArgs(self, args, optdict): """ no-op """ def fromFile(self, filedict, optdict): optdict[self.varName] = filedict.get(self.name, {}) return True class Config(object): """Class holding the configuration to be applied during synchronization. The configuration can be loaded from a file in which case the defaults can be overridden; loading from multiple files allows the settings from later files to override those defined in earlier files. Finally, each configuration option can also be set directly, which is used to allow overriding of file-based configuration settings with those explicitly specified on the command line. """ DEFAULT_RCFILE_NAME = "~/.galileorc" DEFAULT_DUMP_DIR = "~/.galileo" # NOTE TO SELF: When modifying something here, don't forget to propagate the # modifications to the man-pages (under /doc) def __init__(self, opts=None): """ The opts parameter is used by the testsuite """ if opts is None: opts = [ StrParameter('rcConfigName', 'rcconfigname', ('-c', '--config'), None, True, "use alternative configuration file"), StrParameter('dumpDir', 'dump-dir', ('--dump-dir',), "~/.galileo", False, "directory for storing dumps"), IntParameter('daemonPeriod', 'daemon-period', ('--daemon-period',), 15000, False, "sleep time in msec between sync runs when in daemon mode"), SetParameter('includeTrackers', 'include', ('-I', '--include'), None, False, "list of tracker IDs to sync (all if not specified)"), SetParameter('excludeTrackers', 'exclude', ('-X', '--exclude'), set(), False, "list of tracker IDs to not sync"), LogLevelParameter(), BoolParameter('forceSync', 'force-sync', ('force',), False, False, "synchronize even if tracker reports a recent sync"), BoolParameter('keepDumps', 'keep-dumps', ('dump',), True, False, "enable saving of the megadump to file"), BoolParameter('doUpload', 'do-upload', ('upload',), True, False, "upload the dump to the server"), BoolParameter('httpsOnly', 'https-only', ('https-only',), True, False, "use http if https is not available"), StrParameter('fitbitServer', 'fitbit-server', ('-s', '--fitbit-server',), "client.fitbit.com", False, "server used for synchronisation"), IntParameter('logSize', 'log-size', ('--log-size',), 10, False, "Amount of communication to display in case of error"), BoolParameter('syslog', 'syslog', ('syslog',), False, False, "send output to syslog instead of stderr"), Argument(), HardCodedUIConfig(), ] self.__opts = opts self.__optdict = {} for opt in self.__opts: self.__optdict[opt.varName] = opt.default logger.debug("Config default values: %s", self) # not logged def __getattr__(self, name): """ Allow accessing the attributes as config.XXX """ if name not in self.__optdict: raise AttributeError(name) return self.__optdict[name] def parseSystemConfig(self): """ Load the system-wide configuration file """ self.load('/etc/galileo/config') def parseUserConfig(self): """ Load the user based configuration file """ self.load(os.path.join( os.environ.get('XDG_CONFIG_HOME', '~/.config'), 'galileo', 'config')) self.load('~/.galileorc') def load(self, filename): """Load configuration settings from the named YAML-format configuration file. This configuration file can include a subset of possible parameters in which case only those parameters are changed by the load operation. Arguments: - `filename`: The name of the file to load parameters from. """ filename = os.path.expanduser(filename) if not os.path.exists(filename): # Not logged logger.warning('Config file %s does not exists' % filename) return logger.debug('Reading config file %s' % filename) # not logged with open(filename, 'rt') as f: config = yaml.load(f) for param in self.__opts: if not param.fromFile(config, self.__optdict): raise ConfigFileError(filename, param.name) def parseArgs(self): argparser = argparse.ArgumentParser(description="synchronize Fitbit trackers with Fitbit web service", epilog="""Access your synchronized data at http://www.fitbit.com.""") for param in self.__opts: param.toArgParse(argparser) self.cmdlineargs = argparser.parse_args() # And we apply them immediately self.applyArgs() def applyArgs(self): for param in self.__opts: param.fromArgs(self.cmdlineargs, self.__optdict) def shouldSkip(self, tracker): """Method to check, based on the configuration, whether a particular tracker should be skipped and not synchronized. The includeTrackers and excludeTrackers properties are checked to determine this. Arguments: - `tracker`: Tracker (object), to check. """ trackerid = a2x(tracker.id, delim='') # If a list of trackers to sync is configured then was # provided then ignore this tracker if it's not in that list. if (self.includeTrackers is not None) and (trackerid not in self.includeTrackers): logger.info("Include list not empty, and tracker %s not there, skipping.", trackerid) tracker.status = "Skipped because not in include list" return True # If a list of trackers to avoid syncing is configured then # ignore this tracker if it is in that list. if trackerid in self.excludeTrackers: logger.info("Tracker %s in exclude list, skipping.", trackerid) tracker.status = "Skipped because in exclude list" return True if tracker.syncedRecently: if not self.forceSync: logger.info('Tracker %s was recently synchronized; skipping for now', trackerid) tracker.status = "Skipped because recently synchronised" return True logger.info('Tracker %s was recently synchronized, but forcing synchronization anyway', trackerid) return False def __str__(self): return str(self.__optdict) benallard-galileo-f0ebc19c748d/galileo/conversation.py0000644000000000000000000001563212767450640021165 0ustar 00000000000000"""\ The conversationnal part between the server and the client ... """ import base64 import time import uuid import logging logger = logging.getLogger(__name__) from .dongle import FitBitDongle from .net import GalileoClient from .tracker import FitbitClient, MICRODUMP, MEGADUMP from .ui import MissingConfigError from .utils import a2x, s2a FitBitUUID = uuid.UUID('{ADAB0000-6E7D-4601-BDA2-BFFAA68956BA}') class Conversation(object): def __init__(self, mode, ui): self.mode = mode self.ui = ui def __call__(self, config): self.dongle = FitBitDongle(config.logSize) if not self.dongle.setup(): logger.error("No dongle connected, aborting") return self.fitbit = FitbitClient(self.dongle) self.galileo = GalileoClient('https', 'client.fitbit.com', 'tracker/client/message') if self.mode == 'firmware': # Fake the version to let him believe we can handle that ... self.galileo._version = '1.0.0.2575' self.fitbit.disconnect() self.trackers = {} # Dict indexed by trackerId self.connected = None if not self.fitbit.getDongleInfo(): logger.warning('Failed to get connected Fitbit dongle information') action = '' uiresp = [] resp = [('ui-response', {'action': action}, uiresp)] while True: answ = self.galileo.post(self.mode, self.dongle, resp) html = '' commands = None trackers = [] action = None containsForm = False for tple in answ: tag, attribs, childs, _ = tple if tag == "ui-request": action = attribs['action'] for child in childs: tag, attribs, _, body = child if tag == "client-display": containsForm = attribs.get('containsForm', 'false') == 'true' html = body elif tag == 'tracker': trackers.append(tple) elif tag == 'commands': commands = childs if ((not containsForm) and (len(trackers) == 0) and (commands is None)): break resp = [] if trackers: # First: Do what is asked for tracker in trackers: self.do_tracker(tracker) if commands: # Prepare an answer for the server res = [] for command in commands: r = self.do_command(command) print(r) if r is not None: res.append(r) if res: resp.extend(res) if containsForm: # Get an answer from the ui try: ui_resp = self.ui.request(action, html) except MissingConfigError as mce: print(mce) break resp.append(('ui-response', {'action': action}, ui_resp)) print('Done') def __connect(self, id): tracker = self.trackers[id] self.fitbit.establishLink(tracker) self.fitbit.toggleTxPipe(True) self.fitbit.initializeAirlink(tracker) self.connected = tracker #-------- The commands def do_command(self, cmd): tag, elems, childs, body = cmd f = {'pair-to-tracker': self._pair, 'connect-to-tracker': self._connect, 'list-trackers': self._list, 'ack-tracker-data': self._ack}[tag] return f(*childs, **elems) def _pair(self, **params): """ Establish a connection with the tracker. :returns: the minidump """ displayCode = bool(params['displayCode']) waitForUserInput = bool(params['waitForUserInput']) trackerId = params['tracker-id'] self.__connect(trackerId) if displayCode: self.fitbit.displayCode() if waitForUserInput: # XXX: That's waiting, but not for user input ... time.sleep(10) dump = self.fitbit.getDump(MICRODUMP) return ('tracker', {'tracker-id':trackerId}, [('data', {}, [], dump.toBase64())]) def _connect(self, **params): """ :returns: nothing """ trackerId = params['tracker-id'] if self.connected is None: self.__connect(trackerId) if a2x(self.connected.id, delim="") != trackerId: raise ValueError(trackerId) if 'connection' in params: disconnect = params['connection'] == 'disconnect' if disconnect: self.fitbit.terminateAirlink() self.fitbit.toggleTxPipe(False) self.fitbit.ceaseLink() self.connected = None return elif 'response-data' in params: responseData = params['response-data'] dumptype = {'megadump': MEGADUMP, 'microdump': MICRODUMP}[responseData] dump = self.fitbit.getDump(dumptype) return ('tracker', {'tracker-id': trackerId}, [('data', {}, [], dump.toBase64())]) else: raise ValueError(params) def _list(self, *childs, **params): immediateRsi = int(params['immediateRsi']) minDuration = int(params['minDuration']) maxDuration = int(params['maxDuration']) self.trackers = {} res = [] for tracker in self.fitbit.discover(FitBitUUID, minRSSI=immediateRsi, minDuration=minDuration): trackerId = a2x(tracker.id, delim="") self.trackers[trackerId] = tracker res.append(('available-tracker', {}, [('tracker-id', {}, [], trackerId), ('tracker-attributes', {}, [], a2x(tracker.serviceData, delim="")), ('rsi', {}, [], str(tracker.RSSI))])) return ('command-response', {}, [('list-trackers', {}, res)]) def _ack(self, **params): trackerId = params['tracker-id'] # Not much to do here as our ack is part of our upload ... return ('command-response', {}, [('ack-tracker-data', {'tracker-id':trackerId})]) # ------ def do_tracker(self, tracker): tag, elems, childs, body = tracker trackerId = elems['tracker-id'] if a2x(self.connected.id, delim="") != trackerId: raise ValueError(trackerId) _type = elems['type'] if _type != 'megadumpresponse': raise NotImplementedError(_type) data = None for child in childs: tag, _, _, body = child if tag == 'data': data = s2a(base64.b64decode(body)) self.fitbit.uploadResponse(data) benallard-galileo-f0ebc19c748d/galileo/dongle.py0000644000000000000000000002350312767450640017717 0ustar 00000000000000from __future__ import print_function import errno import logging logger = logging.getLogger(__name__) try: import usb.core except ImportError as ie: # if ``usb`` is there, but not ``usb.core``, a pre-1.0 version of pyusb # is installed. try: import usb except ImportError: pass else: print("You have an older pyusb version installed. This utility needs") print("at least version 1.0.0a2 to work properly.") print("Please upgrade your system to a newer version.") raise ie from .utils import a2x, a2s IN, OUT = 1, -1 class DataRing(object): """ A 'stupid' data structure that store not more that capacity elements, and keeps them in order head points to the next spot queue points to the last spot fill tell us how much is filled """ def __init__(self, capacity): self.capacity = capacity self.ring = [None] * self.capacity self.head = 0 self.queue = 0 # We can't distinguish empty from full without the fillage self.fill = 0 @property def empty(self): return self.fill == 0 @property def full(self): return self.fill == self.capacity def add(self, data): if self.capacity == 0: # Special case, do nothing return if self.full: # full, don't forget to increase the queue self.queue = (self.queue + 1) % self.capacity self.ring[self.head] = data self.head = (self.head + 1) % self.capacity self.fill = min(self.fill + 1, self.capacity) def remove(self): """ For the fun, doesnt fit into our use case """ if self.empty: # NOOP return self.queue = (self.queue - 1) % self.capacity def getData(self): if self.empty: return [] elif self.queue < self.head: return self.ring[self.queue:self.head] else: return self.ring[self.queue:] + self.ring[:self.head] class USBDevice(object): def __init__(self, vid, pid): self.vid = vid self.pid = pid self._dev = None @property def dev(self): if self._dev is None: self._dev = usb.core.find(idVendor=self.vid, idProduct=self.pid) return self._dev def __del__(self): if hasattr(self, '_dev') and self._dev is not None: self._dev.reset() class CtrlMessage(object): """ A message that get communicated over the ctrl link """ def __init__(self, INS, data=[]): if INS is None: # incoming self.len = data[0] self.INS = data[1] self.payload = data[2:self.len] else: # outgoing self.len = len(data) + 2 self.INS = INS self.payload = data def asList(self): return [self.len, self.INS] + self.payload def __eq__(self, other): if other is None: return False return self.asList() == other.asList() def __ne__(self, other): return not self == other def __str__(self): d = [] if self.payload: d = ['(', a2x(self.payload), ')'] return ' '.join(['%02X' % self.INS] + d + ['-', str(self.len)]) CM = CtrlMessage class DataMessage(object): """ A message that get communicated over the data link """ LENGTH = 32 def __init__(self, data, out=True): if out: # outgoing if len(data) > (self.LENGTH - 1): raise ValueError('data %s (%d) too big' % (data, len(data))) self.data = data self.len = len(data) else: # incoming if len(data) != self.LENGTH: raise ValueError('data %s with wrong length' % data) # last byte is length self.len = data[-1] self.data = list(data[:self.len]) def asList(self): return self.data + [0] * (self.LENGTH - 1 - self.len) + [self.len] def __eq__(self, other): if other is None: return False return self.data == other.data def __ne__(self, other): return not self == other def __str__(self): return ' '.join(['[', a2x(self.data), ']', '-', str(self.len)]) DM = DataMessage def isATimeout(excpt): if excpt.errno == errno.ETIMEDOUT: return True elif excpt.errno is None and excpt.args == ('Operation timed out',): return True elif excpt.errno is None and excpt.strerror == 'Connection timed out': return True else: return False class DongleWriteException(Exception): pass class PermissionDeniedException(Exception): pass def isStatus(data, msg=None, logError=True): if data is None: return False if data.INS != 1: if logError: logging.warning("Message is not a status message: %x", data.INS) return False if msg is None: return True message = a2s(data.payload) if not message.startswith(msg): if logError: logging.warning("Message '%s' (received) is not '%s' (expected)", message, msg) return False return True class FitBitDongle(USBDevice): VID = 0x2687 PID = 0xfb01 def __init__(self, logsize): USBDevice.__init__(self, self.VID, self.PID) self.hasVersion = False self.establishLinkEx = False self.newerPyUSB = None global log log = DataRing(logsize) def setup(self): if self.dev is None: return False try: if self.dev.is_kernel_driver_active(0): self.dev.detach_kernel_driver(0) if self.dev.is_kernel_driver_active(1): self.dev.detach_kernel_driver(1) except usb.core.USBError as ue: if ue.errno == errno.EACCES: logger.error('Insufficient permissions to access the Fitbit' ' dongle') # Don't try to cleanup the connection in the destructor del self._dev raise PermissionDeniedException raise except NotImplementedError as nie: logger.error("Hit some 'Not Implemented Error': '%s', moving on ...", nie) cfg = self.dev.get_active_configuration() self.DataIF = cfg[(0, 0)] self.CtrlIF = cfg[(1, 0)] self.dev.set_configuration() return True def setVersion(self, major, minor): self.major = major self.minor = minor self.hasVersion = True # Leave it to False, I don't see any advantage in using it at the moment # self.establishLinkEx = (major, minor) >= (2, 5) logger.debug('Fitbit dongle version major:%d minor:%d', self.major, self.minor) def write(self, endpoint, data, timeout): if self.newerPyUSB: params = (endpoint, data, timeout) else: interface = {0x02: self.CtrlIF.bInterfaceNumber, 0x01: self.DataIF.bInterfaceNumber}[endpoint] params = (endpoint, data, interface, timeout) log.add((OUT, data)) try: return self.dev.write(*params) except TypeError: if self.newerPyUSB is not None: # Already been there, something else is happening ... raise logger.debug('Switching to a newer pyusb compatibility mode') self.newerPyUSB = True return self.write(endpoint, data, timeout) except usb.core.USBError as ue: if ue.errno != errno.EIO: raise logger.info('Caught an I/O Error while writing, trying again ...') # IO Error, try again ... return self.dev.write(*params) def read(self, endpoint, length, timeout): if self.newerPyUSB: params = (endpoint, length, timeout) else: interface = {0x82: self.CtrlIF.bInterfaceNumber, 0x81: self.DataIF.bInterfaceNumber}[endpoint] params = (endpoint, length, interface, timeout) data = None try: data = self.dev.read(*params) except TypeError: if self.newerPyUSB is not None: # Already been there, something else is happening ... raise logger.debug('Switching to a newer pyusb compatibility mode') self.newerPyUSB = True return self.read(endpoint, length, timeout) except usb.core.USBError as ue: if not isATimeout(ue): raise logger.info('Got an I/O Timeout (> %dms) while reading!', timeout) log.add((IN, data)) return data def ctrl_write(self, msg, timeout=2000): logger.debug('--> %s', msg) l = self.write(0x02, msg.asList(), timeout) if l != msg.len: logger.error('Bug, sent %d, had %d', l, msg.len) raise DongleWriteException def ctrl_read(self, timeout=2000, length=32): msg = None data = self.read(0x82, length, timeout) if data is not None: # 'None' parameter in next line means incoming msg = CM(None, list(data)) if msg is None: logger.debug('<-- ...') elif isStatus(msg, logError=False): logger.debug('<-- %s', a2s(msg.payload)) else: logger.debug('<-- %s', msg) return msg def data_write(self, msg, timeout=2000): logger.debug('==> %s', msg) l = self.write(0x01, msg.asList(), timeout) if l != msg.LENGTH: logger.error('Bug, sent %d, had %d', l, msg.LENGTH) raise DongleWriteException def data_read(self, timeout=2000): msg = None data = self.read(0x81, DM.LENGTH, timeout) if data is not None: msg = DM(data, out=False) logger.debug('<== %s', msg or '...') return msg benallard-galileo-f0ebc19c748d/galileo/dump.py0000644000000000000000000000761412767450640017421 0ustar 00000000000000import base64 import logging logger = logging.getLogger(__name__) from .utils import a2x, a2lsbi, a2b class CRC16(object): """ A rather generic CRC16 class """ def __init__(self, poly=0x1021, Invert=True, IV=0x0000, FV=0x0000): self.poly = poly self.value = IV self.FV = FV if Invert: self.update_byte = self.update_byte_MSB else: self.update_byte = self.update_byte_LSB def update_byte_MSB(self, byte): self.value ^= byte << 8 for i in range(8): if self.value & 0x8000: self.value = (self.value << 1) ^ self.poly else: self.value <<= 1 self.value &= 0xffff def update_byte_LSB(self, byte): self.value ^= byte for i in range(8): if self.value & 0x0001: self.value = (self.value >> 1) ^ self.poly else: self.value >>= 1 def update(self, array): for c in array: self.update_byte(c) def final(self): return self.value ^ self.FV class Dump(object): def __init__(self, _type): self._type = _type self.data = [] self.footer = [] self.crc = CRC16() self.esc = [0, 0] def unSLIP1(self, data): """ The protocol uses a particular version of SLIP (RFC 1055) applied only on the first byte of the data""" END = 0xC0 ESC = 0xDB ESC_ = {0xDC: END, 0xDD: ESC} if data[0] == ESC: # increment the escape counter self.esc[data[1] - 0xDC] += 1 # return the escaped value return [ESC_[data[1]]] + data[2:] return data def add(self, data): if data[0] == 0xc0: assert self.footer == [] self.footer = data return data = self.unSLIP1(data) self.crc.update(data) self.data.extend(data) @property def len(self): return len(self.data) def isValid(self): if not self.footer: return False dataType = self.footer[2] if dataType != self._type: logger.error('Dump is not of requested type: %x != %x', dataType, self._type) return False crcVal = self.crc.final() transportCRC = a2lsbi(self.footer[3:5]) if transportCRC != crcVal: logger.error("Error in communication, Expected CRC: 0x%04X," " received 0x%04X", crcVal, transportCRC) return False nbBytes = a2lsbi(self.footer[5:9]) if self.len != nbBytes: logger.error("Error in communication, Expected length: %d bytes," " received %d bytes", nbBytes, self.len) return False return True def toFile(self, filename): logger.debug("Dumping megadump to %s", filename) with open(filename, 'wt') as dumpfile: for i in range(0, self.len, 20): dumpfile.write(a2x(self.data[i:i + 20]) + '\n') dumpfile.write(a2x(self.footer) + '\n') def toBase64(self): return base64.b64encode(a2b(self.data + self.footer)).decode('utf-8') class DumpResponse(object): def __init__(self, data, chunk_len): self.data = data self._chunk_len = chunk_len self.__index = 0 def __iter__(self): return self def __next__(self): if self.__index >= len(self.data): raise StopIteration if self.data[self.__index] not in (0xC0, 0xDB): self.__index += self._chunk_len return self.data[self.__index-self._chunk_len:self.__index] b = self.data[self.__index] self.__index += self._chunk_len - 1 return [0xDB] + [{0xC0: 0xDC, 0xDB: 0xDD}[b]] + self.data[self.__index-self._chunk_len+2:self.__index] # For python2 next = __next__ benallard-galileo-f0ebc19c748d/galileo/interactive.py0000644000000000000000000001215412767450640020764 0ustar 00000000000000"""\ This is the implementation of the interactive mode This is the same idea as ifitbit I wrote for libfitbit some years ago https://bitbucket.org/benallard/libfitbit/src/tip/python/ifitbit.py?at=default """ from __future__ import print_function try: # Override the input from python2 with raw_input input = raw_input except NameError: pass #--------------------------- # The engine import readline import traceback import sys exit = None 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('x', "Quit") def quit(): print('Bye !') global exit exit = True @command('?', 'Print possible commands') def print_help(): for cmd in sorted(helps.keys()): print('%s\t%s' % (cmd, helps[cmd])) print("""Note: - You can enter multiple commands separated by ';' - To establish a link with the tracker, enter the following command: c ; d ; l ; tx 1 ; al """) def main(config): global exit exit = False print_help() while not exit: orders = input('> ').strip() if ';' in orders: orders = orders.split(';') else: orders = [orders] for order in orders: order = order.strip().split(' ') try: f = cmds[order[0]] except KeyError: if order[0] == '': continue print('Command %s not known' % order[0]) print_help() continue try: f(*order[1:]) except TypeError as te: print("Wrong number of argument given: %s" % te) except Exception as e: # We need that to be able to close the connection nicely print("BaD bAd BAd", e) traceback.print_exc(file=sys.stdout) return #--------------------------- # The commands from .dongle import FitBitDongle, CM, DM from .tracker import FitbitClient from .utils import x2a import uuid dongle = None fitbit = None trackers = [] tracker = None @command('c', "Connect") def connect(): global dongle dongle = FitBitDongle(0) # No DataRing needed if not dongle.setup(): print("No dongle connected, aborting") quit() global fitbit fitbit = FitbitClient(dongle) print('Ok') def needfitbit(fn): def wrapped(*args): if dongle is None: print("No connection, connect (c) first") return return fn(*args) return wrapped @command('->', "Send on the control channel") @needfitbit def send_ctrl(INS, *payload): if payload: payload = x2a(' '.join(payload)) else: payload = [] m = CM(int(INS, 16), payload) dongle.ctrl_write(m) @command('<-', "Receive once on the control channel") @needfitbit def receive_ctrl(param='1'): if param == '-': goOn = True while goOn: goOn = dongle.ctrl_read() is not None else: for i in range(int(param)): dongle.ctrl_read() @command('=>', "Send on the control channel") @needfitbit def send_data(*payload): m = DM(x2a(' '.join(payload))) dongle.data_write(m) @command('<=', "Receive once on the control channel") @needfitbit def receive_data(param='1'): if param == '-': goOn = True while goOn: goOn = dongle.data_read() is not None else: for i in range(int(param)): dongle.data_read() @command('d', "Discovery") @needfitbit def discovery(UUID="{ADAB0000-6E7D-4601-BDA2-BFFAA68956BA}"): UUID = uuid.UUID(UUID) global trackers trackers = [t for t in fitbit.discover(UUID)] def needtrackers(fn): def wrapped(*args): if not trackers: print("No trackers, run a discovery (d) first") return return fn(*args) return wrapped @command('l', "establishLink") @needtrackers def establishLink(idx='0'): global tracker tracker = trackers[int(idx)] if fitbit.establishLink(tracker): print('Ok') else: tracker = None @command('L', "ceaseLink") @needfitbit def ceaseLink(): if not fitbit.ceaseLink(): print('Bad') else: print('Ok') def needtracker(fn): def wrapped(*args): if tracker is None: print("No tracker, establish a Link (l) first") return return fn(*args) return wrapped @command('tx', "toggle Tx Pipe") @needfitbit def toggleTxPipe(on): if fitbit.toggleTxPipe(bool(int(on))): print('Ok') @command('al', "initialise airLink") @needtracker def initialiseAirLink(): if fitbit.initializeAirlink(tracker): print('Ok') @command('AL', "terminate airLink") @needfitbit def terminateairLink(): if fitbit.terminateAirlink(): print('Ok') @command('D', 'getDump') @needfitbit def getDump(type="13"): fitbit.getDump(int(type)) @command('R', 'uploadResponse') @needfitbit def uploadResponse(*response): response = x2a(' '.join(response)) fitbit.uploadResponse(response) benallard-galileo-f0ebc19c748d/galileo/main.py0000644000000000000000000002603012767450640017371 0ustar 00000000000000from __future__ import print_function import datetime import os import sys import time import uuid import logging import logging.handlers logger = logging.getLogger(__name__) import requests from . import __version__ from .config import Config, ConfigError from .conversation import Conversation from .net import GalileoClient, SyncError, BackOffException from .tracker import FitbitClient from .ui import InteractiveUI from .utils import a2x from . import dongle as dgl from . import interactive FitBitUUID = uuid.UUID('{ADAB0000-6E7D-4601-BDA2-BFFAA68956BA}') def syncAllTrackers(config): logger.debug('%s initialising', os.path.basename(sys.argv[0])) dongle = dgl.FitBitDongle(config.logSize) if not dongle.setup(): logger.error("No dongle connected, aborting") return fitbit = FitbitClient(dongle) galileo = GalileoClient('https', config.fitbitServer, 'tracker/client/message') if not fitbit.disconnect(): logger.error("Dirty state, not able to start synchronisation.") fitbit.exhaust() return if not fitbit.getDongleInfo(): logger.warning('Failed to get connected Fitbit dongle information') logger.info('Discovering trackers to synchronize') trackers = [t for t in fitbit.discover(FitBitUUID)] logger.info('%d trackers discovered', len(trackers)) for tracker in trackers: logger.debug('Discovered tracker with ID %s', a2x(tracker.id, delim="")) for tracker in trackers: trackerid = a2x(tracker.id, delim="") # Skip this tracker based on include/exclude lists. if config.shouldSkip(tracker): logger.info('Tracker %s skipped due to configuration', trackerid) yield tracker continue logger.info('Attempting to synchronize tracker %s', trackerid) if config.doUpload: logger.debug('Connecting to Fitbit server and requesting status') if not galileo.requestStatus(not config.httpsOnly): yield tracker break logger.debug('Establishing link with tracker') if not (fitbit.establishLink(tracker) and fitbit.toggleTxPipe(True) and fitbit.initializeAirlink(tracker)): logger.warning('Unable to connect with tracker %s. Skipping', trackerid) tracker.status = 'Unable to establish a connection.' yield tracker continue #fitbit.displayCode() #time.sleep(5) logger.info('Getting data from tracker') dump = fitbit.getDump() if dump is None: logger.error("Error downloading the dump from tracker") tracker.status = "Failed to download the dump" yield tracker continue if config.keepDumps: # Write the dump somewhere for archiving ... dirname = os.path.expanduser(os.path.join(config.dumpDir, trackerid)) if not os.path.exists(dirname): logger.debug("Creating non-existent directory for dumps %s", dirname) os.makedirs(dirname) filename = os.path.join(dirname, 'dump-%d.txt' % int(time.time())) dump.toFile(filename) else: logger.debug("Not dumping anything to disk") if not config.doUpload: logger.info("Not uploading, as asked ...") else: logger.info('Sending tracker data to Fitbit') try: response = galileo.sync(fitbit.dongle, trackerid, dump) if config.keepDumps: logger.debug("Appending answer from server to %s", filename) with open(filename, 'at') as dumpfile: dumpfile.write('\n') for i in range(0, len(response), 20): dumpfile.write(a2x(response[i:i + 20]) + '\n') # Even though the next steps might fail, fitbit has accepted # the data at this point. tracker.status = "Dump successfully uploaded" logger.info('Successfully sent tracker data to Fitbit') logger.info('Passing Fitbit response to tracker') if not fitbit.uploadResponse(response): logger.warning("Error while trying to give Fitbit response" " to tracker %s", trackerid) tracker.status = "Failed to upload fitbit response to tracker" else: tracker.status = "Synchronisation successful" except SyncError as e: logger.error("Fitbit server refused data from tracker %s," " reason: %s", trackerid, e.errorstring) tracker.status = "Synchronisation failed: %s" % e.errorstring logger.debug('Disconnecting from tracker') if not (fitbit.terminateAirlink() and fitbit.toggleTxPipe(False) and fitbit.ceaseLink()): logger.warning('Error while disconnecting from tracker %s', trackerid) tracker.status += " (Error disconnecting)" yield tracker PERMISSION_DENIED_HELP = """ To be able to run the fitbit utility as a non-privileged user, you first should install a 'udev rule' that lower the permissions needed to access the fitbit dongle. In order to do so, as root, create the file /etc/udev/rules.d/99-fitbit.rules with the following content (in one line): SUBSYSTEM=="usb", ATTR{idVendor}=="%(VID)x", ATTR{idProduct}=="%(PID)x", SYMLINK+="fitbit", MODE="0666" The dongle must then be removed and reinserted to receive the new permissions.""" % { 'VID': dgl.FitBitDongle.VID, 'PID': dgl.FitBitDongle.PID} def version(verbose, delim='\n'): s = ['%s: %s' % (sys.argv[0], __version__)] if verbose: import usb import platform from .config import yaml # To get it on one line s.append('Python: %s' % ' '.join(sys.version.split())) s.append('Platform: %s' % ' '.join(platform.uname())) if not hasattr(usb, '__version__'): s.append('pyusb: < 1.0.0b1') else: s.append('pyusb: %s' % usb.__version__) s.append('requests: %s' % requests.__version__) if hasattr(yaml, '__with_libyaml__'): # Genuine PyYAML s.append('yaml: %s (%s libyaml)' % ( yaml.__version__, yaml.__with_libyaml__ and 'with' or 'without')) else: # Custom version s.append('yaml: own version') return delim.join(s) def version_mode(config): print(version(config.logLevel in (logging.INFO, logging.DEBUG))) def sync(config): statuses = [] try: for tracker in syncAllTrackers(config): statuses.append("Tracker: %s: %s" % (a2x(tracker.id, ''), tracker.status)) except BackOffException as boe: print("The server requested that we come back between %d and %d"\ " minutes." % (boe.min / (60*1000), boe.max / (60*1000))) later = datetime.datetime.now() + datetime.timedelta( microseconds=boe.getAValue()*1000) print("I suggest waiting until %s" % later) return except dgl.PermissionDeniedException: print(PERMISSION_DENIED_HELP) return print('\n'.join(statuses)) def daemon(config): goOn = True while goOn: try: # TODO: Extract the initialization part, and do it once for all try: for tracker in syncAllTrackers(config): logger.info("Tracker %s: %s" % (a2x(tracker.id, ''), tracker.status)) except BackOffException as boe: logger.warning("Received a back-off notice from the server," " waiting for a bit longer.") time.sleep(boe.getAValue() / 1000.) else: logger.info("Sleeping for %d seconds before next sync", config.daemonPeriod / 1000) time.sleep(config.daemonPeriod / 1000.) except KeyboardInterrupt: logger.info("Ctrl-C, caught, stopping ...") goOn = False def main(): """ This is the entry point """ # Set the null handler to avoid complaining about no handler presents import galileo logging.getLogger(galileo.__name__).addHandler(logging.NullHandler()) try: config = Config() config.parseSystemConfig() config.parseUserConfig() # This gives us the config file name config.parseArgs() if config.rcConfigName: config.load(config.rcConfigName) # We need to re-apply our arguments as last config.applyArgs() except ConfigError as e: print(e, file=sys.stderr) sys.exit(os.EX_CONFIG) # --- All logging actions before this line are not active --- # This means that the whole Config parsing is not logged because we don't # know which logLevel we should use. if config.syslog: # Syslog messages must have the time/name first. format = ('%(asctime)s ' + galileo.__name__ + ': ' '%(levelname)s: %(module)s: %(message)s') handler = logging.handlers.SysLogHandler( address='/dev/log', facility=logging.handlers.SysLogHandler.LOG_DAEMON) handler.setFormatter(logging.Formatter(fmt=format)) core_logger = logging.getLogger(galileo.__name__) core_logger.handlers = [] core_logger.addHandler(handler) core_logger.setLevel(config.logLevel) else: format = '%(asctime)s:%(levelname)s: %(message)s' logging.basicConfig(format=format, level=config.logLevel) # --- All logger actions from now on will be effective --- logger.debug("Configuration: %s", config) ui = InteractiveUI(config.hardcoded_ui) try: { 'version': version_mode, 'sync': sync, 'daemon': daemon, 'pair': Conversation('pair', ui), 'firmware': Conversation('firmware', ui), 'interactive': interactive.main, }[config.mode](config) except: logger.critical("# A serious error happened, which is probably due to a") logger.critical("# programming error. Please open a new issue with the following") logger.critical("# information on the galileo bug tracker:") logger.critical("# https://bitbucket.org/benallard/galileo/issues/new") logger.critical('# %s', version(True, '\n# ')) if hasattr(dgl, 'log'): logger.critical('# Last communications:') for comm in dgl.log.getData(): dir, dat = comm logger.critical('# %s %s' % ({dgl.IN: '<', dgl.OUT: '>'}.get(dir, '-'), a2x(dat or []))) logger.critical("#", exc_info=True) sys.exit(os.EX_SOFTWARE) benallard-galileo-f0ebc19c748d/galileo/net.py0000644000000000000000000002007412767450640017235 0ustar 00000000000000 import base64 import random import socket from io import BytesIO import xml.etree.ElementTree as ET import logging logger = logging.getLogger(__name__) import requests from . import __version__ from .utils import s2a class SyncError(Exception): def __init__(self, errorstring='Undefined'): self.errorstring = errorstring class BackOffException(Exception): def __init__(self, min, max): self.min = min self.max = max def getAValue(self): return random.randint(self.min, self.max) def toXML(name, attrs={}, childs=[], body=None): elem = ET.Element(name, attrib=attrs) if childs: for XMLElem in tuplesToXML(childs): elem.append(XMLElem) if body is not None: elem.text = body return elem def tuplesToXML(tuples): """ tuples is an array (or not) of (name, attrs, childs, body) """ if isinstance(tuples, tuple): tuples = [tuples] for tpl in tuples: yield toXML(*tpl) def XMLToTuple(elem): """ Transform an XML element into the following tuple: (tagname, attributes, subelements, text) where: - tagname is the element tag as string - attributes is a dictionnary of the element attributes - subelements are the sub elements as an array of tuple - text is the content of the element, as string or None if no content is there """ childs = [] for child in elem: childs.append(XMLToTuple(child)) return elem.tag, elem.attrib, childs, elem.text def ConnectionErrorToMessage(ce): excpt = ce.args[0] if isinstance(excpt, socket.error): return excpt.reason.strerror return 'ConnectionError' class GalileoClient(object): ID = '6de4df71-17f9-43ea-9854-67f842021e05' def __init__(self, scheme, host, path, port=None): self.scheme = scheme self.host = host self.path = path self._port = port self.server_state = None self._version = None @property def port(self): if self._port is None: return {'http': 80, 'https': 443}[self.scheme] return self._port @property def url(self): return "%(scheme)s://%(host)s:%(port)d/%(path)s" % { 'scheme': self.scheme, 'host': self.host, 'port': self.port, 'path': self.path} @property def version(self): if self._version is not None: # We're not completely lying ;) return self._version + ' (really: %s)' % __version__ return __version__ def post(self, mode, dongle=None, data=None): client = toXML('galileo-client', {'version': "2.0"}) info = toXML('client-info', childs=[ ('client-id', {}, [], self.ID), ('client-version', {}, [], self.version), ('client-mode', {}, [], mode)]) if (dongle is not None) and dongle.hasVersion: info.append(toXML( 'dongle-version', {'major': str(dongle.major), 'minor': str(dongle.minor)})) client.append(info) if self.server_state is not None: client.append(toXML('server-state', body=self.server_state)) if data is not None: for XMLElem in tuplesToXML(data): client.append(XMLElem) f = BytesIO() tree = ET.ElementTree(client) tree.write(f, "utf-8", xml_declaration=True) logger.debug('HTTP POST=%s', f.getvalue()) r = requests.post(self.url, data=f.getvalue(), headers={"Content-Type": "text/xml"}) f.close() r.raise_for_status() try: answer = r.text except AttributeError: answer = r.content logger.debug('HTTP response=%s', answer) tag, attrib, childs, body = XMLToTuple(ET.fromstring( answer.encode('utf-8'))) if tag != 'galileo-server': logger.error("Unexpected root element: %s", tag) if attrib['version'] != "2.0": logger.warning("Unexpected server version: %s", attrib['version']) excpt = None for child in childs: stag, _, schilds, sbody = child if stag == 'error': excpt = SyncError(sbody) elif stag == 'back-off': minD = 0 maxD = 0 for schild in schilds: sstag, _, _, ssbody = schild if sstag == 'min': minD = int(ssbody) if sstag == 'max': maxD = int(ssbody) excpt = BackOffException(minD, maxD) elif stag == 'server-state': self.server_state = sbody elif stag == 'redirect': for schild in schilds: sstag, _, _, ssbody = schild if sstag == 'protocol': self.scheme = ssbody if sstag == 'host': self.host = ssbody if sstag == 'port': self._port = int(ssbody) logger.info('Found redirect to %s' % self.url) elif stag == 'tracker': # We rely on the fact that this tag comes at last if excpt is not None: logger.warning("Discarding exception: %s", excpt) excpt = None if excpt is not None: raise excpt return childs def requestStatus(self, allowHTTP=False): try: self.post('status') except requests.exceptions.ConnectionError as ce: error_msg = ConnectionErrorToMessage(ce) # No internet connection or fitbit server down logger.error("Not able to connect to the Fitbit server using %s:" " %s.", self.scheme.upper(), error_msg) else: return True if self.scheme == 'https' and not allowHTTP: logger.warning('Config disallow the fallback to HTTP, you might' ' want to give it a try (--no-https-only)') if self.scheme == 'http' or not allowHTTP: return False logger.info('Trying http as a backup.') self.scheme = 'http' try: self.post('status') except requests.exceptions.ConnectionError as ce: error_msg = ConnectionErrorToMessage(ce) # No internet connection or fitbit server down logger.error("Not able to connect to the Fitbit server using" " either HTTP or HTTPS (%s). Check your internet" " connection", error_msg) else: return True return False def sync(self, dongle, trackerId, megadump): try: server = self.post('sync', dongle, ( 'tracker', {'tracker-id': trackerId}, ( 'data', {}, [], megadump.toBase64()))) except requests.exceptions.ConnectionError as ce: error_msg = ConnectionErrorToMessage(ce) raise SyncError('ConnectionError: %s' % error_msg) except requests.exceptions.HTTPError as he: status_code = 500 if getattr(he, 'response', None) is not None: status_code = he.response.status_code msg = he.args[0] raise SyncError("HTTPError: %s (%d)" % (msg, status_code)) tracker = None for elem in server: if elem[0] == 'tracker': tracker = elem break if tracker is None: raise SyncError('no tracker') _, a, c, _ = tracker if a['tracker-id'] != trackerId: logger.error("Got the response for tracker %s, expected tracker" " %s", a['tracker-id'], trackerId) if a['type'] != 'megadumpresponse': logger.error('Not a megadumpresponse: %s', a['type']) if not c: raise SyncError('no data') if len(c) != 1: logger.error("Unexpected childs length: %d", len(c)) t, _, _, d = c[0] if t != 'data': raise SyncError('not data: %s' % t) return s2a(base64.b64decode(d)) benallard-galileo-f0ebc19c748d/galileo/parser.py0000644000000000000000000000557712767450640017756 0ustar 00000000000000"""\ This is a custom implementation of the yaml parser in order to prevent an extra dependency in the PyYAML module. This implementation will be used when the PyYAML module will not be found. The configurability of galileo should not be based on the possibility of this parser. This parser should be adapted to allow the correct configuration. Known limitations: - Only spaces, no tabs - Blank lines in the middle of an indented block is pretty bad ... """ from __future__ import print_function # for the __main__ block import json import textwrap def _stripcomment(line): s = [] for c in line: if c == '#': break s.append(c) # And we strip the trailing spaces return ''.join(s).rstrip() def _getident(line): i = 0 for c in line: if c != ' ': break i += 1 return i def _addKey(d, key): if d is None and key: d = {} d[key] = None return d def unJSONize(s): """ json is not good enough ... "'a'" doesn't get decoded, even worst, "a" neither """ try: return json.loads(s) except ValueError: s = s.strip() if s[0] == "'" and s[-1] == "'": return s[1:-1] return s def _dedent(lines, start): res = [lines[start]] idx = start + 1 minident = _getident(lines[start]) while idx < len(lines): curident = _getident(lines[idx]) if curident < minident: break res.append(lines[idx]) idx += 1 return res def loads(s): res = None current_key = None lines = s.split('\n') i = 0 while i < len(lines): line = _stripcomment(lines[i]) i += 1 if not line: continue if _getident(line) == 0: if line.startswith('-'): if res is None: res = [] line = line[1:].strip() if line: res.append(loads(line)) elif i == len(lines): res.append(None) elif ':' in line: current_key = None k, v = line.split(':') res = _addKey(res, k) if not v: current_key = k else: res[k] = unJSONize(v) else: return unJSONize(line) else: subblock = _dedent(lines, i-1) subres = loads(textwrap.dedent('\n'.join(subblock))) if isinstance(res, dict): res[current_key] = subres elif isinstance(res, list): res.append(subres) else: raise ValueError(res, subres) i += len(subblock) - 1 return res def load(f): return loads(f.read()) if __name__ == "__main__": import sys # For fun and quick test with open(sys.argv[1], 'rt') as f: print(load(f)) benallard-galileo-f0ebc19c748d/galileo/tracker.py0000644000000000000000000003177212767450640020111 0ustar 00000000000000from ctypes import c_byte import logging logger = logging.getLogger(__name__) from .dongle import CM, DM, isStatus from .dump import Dump, DumpResponse from .utils import a2s, a2x, i2lsba, a2lsbi MICRODUMP = 3 MEGADUMP = 13 class Tracker(object): def __init__(self, Id, addrType, serviceData, RSSI, serviceUUID=None): self.id = Id self.addrType = addrType if serviceUUID is None: self.serviceUUID = a2lsbi([Id[1] ^ Id[3] ^ Id[5], Id[0] ^ Id[2] ^ Id[4]]) else: self.serviceUUID = serviceUUID self.serviceData = serviceData # following three are coded somewhere here ... # specialMode # canDisplayNumber # colorCode self.RSSI = RSSI self.status = 'unknown' # If we happen to read it before anyone set it @property def productId(self): return self.serviceData[0] @property def syncedRecently(self): return self.serviceData[1] != 4 @classmethod def fromDiscovery(klass, data, minRSSI=-255): trackerId = data[:6] addrType = data[6] RSSI = c_byte(data[7]).value serviceDataLen = data[8] serviceData = data[9:9+serviceDataLen+1] # '+1': go figure ! sUUID = a2lsbi(data[15:17]) serviceUUID = a2lsbi([trackerId[1] ^ trackerId[3] ^ trackerId[5], trackerId[0] ^ trackerId[2] ^ trackerId[4]]) tracker = klass(trackerId, addrType, serviceData, RSSI, sUUID) if not tracker.syncedRecently and (serviceUUID != sUUID): logger.debug("Cannot acknowledge the serviceUUID: %s vs %s", a2x(i2lsba(serviceUUID, 2), ':'), a2x(i2lsba(sUUID, 2), ':')) logger.debug('Tracker: %s, %s, %s, %s', a2x(trackerId, ':'), addrType, RSSI, a2x(serviceData, ':')) if RSSI < -80: logger.info("Tracker %s has low signal power (%ddBm), higher" " chance of miscommunication", a2x(trackerId, delim=""), RSSI) if not tracker.syncedRecently: logger.debug('Tracker %s was not recently synchronized', a2x(trackerId, delim="")) if RSSI < minRSSI: logger.warning("Tracker %s below power threshold (%ddBm)," "dropping", a2x(trackerId, delim=""), minRSSI) #continue return tracker class FitbitClient(object): def __init__(self, dongle): self.dongle = dongle def disconnect(self): logger.info('Disconnecting from any connected trackers') self.dongle.ctrl_write(CM(2)) if not isStatus(self.dongle.ctrl_read(), 'CancelDiscovery'): return False # Next one is not critical. It can happen that it does not comes isStatus(self.dongle.ctrl_read(), 'TerminateLink') self.exhaust() return True def exhaust(self): """ We exhaust the pipe, then we know that we have a clean state """ logger.debug("Exhausting the communication pipe") goOn = True while goOn: goOn = self.dongle.ctrl_read() is not None def getDongleInfo(self): self.dongle.ctrl_write(CM(1)) d = self.dongle.ctrl_read() if (d is None) or (d.INS != 8): return False self.dongle.setVersion(d.payload[0], d.payload[1]) self.dongle.address = d.payload[2:8] self.dongle.flashEraseTime = a2lsbi(d.payload[8:10]) self.dongle.firmwareStartAddress = a2lsbi(d.payload[10:14]) self.dongle.firmwareEndAddress = a2lsbi(d.payload[14:18]) self.dongle.ccIC = d.payload[18] # Not sure how the last ones fit in the last byte # self.dongle.hardwareRevision = d.payload[19] # self.dongle.revision = d.payload[19] return True def discover(self, uuid, service1=0xfb00, write=0xfb01, read=0xfb02, minRSSI=-255, minDuration=4000): """\ The uuid is a mask on the service (characteristics ?) we understand service1 parameter is unused (at lease for the 'One') read and write are the uuid of the characteristics we use for transmission and reception. """ logger.debug('Discovering for UUID %s: %s', uuid, ', '.join(hex(s) for s in (service1, write, read))) data = i2lsba(uuid.int, 16) for i in (service1, write, read, minDuration): data += i2lsba(i, 2) self.dongle.ctrl_write(CM(4, data)) amount = 0 while True: # Give the dongle 100ms margin d = self.dongle.ctrl_read(minDuration + 100) if d is None: break elif isStatus(d, None, False): # We know this can happen almost any time during 'discovery' logger.info('Ignoring message: %s' % a2s(d.payload)) continue elif d.INS == 2: # Last instruction of a discovery sequence has INS==1 break elif (d.INS != 3) or (len(d.payload) < 17): logger.error('payload unexpected: %s', d) break yield Tracker.fromDiscovery(d.payload, minRSSI) amount += 1 if d != CM(2, [amount]): logger.error('%d trackers discovered, dongle says %s', amount, d) # tracker found, cancel discovery self.dongle.ctrl_write(CM(5)) d = self.dongle.ctrl_read() if isStatus(d, 'StartDiscovery', False): # We had not received the 'StartDiscovery' yet d = self.dongle.ctrl_read() isStatus(d, 'CancelDiscovery') def setPowerLevel(self, level): # This is quite weird as in the log I took this from, they send: # 020D05 (level5), but as the length is 02, I believe the 05 is not # even acknowledged by the dongle ... self.dongle.ctrl_write(CM(0xd, [level])) r = self.dongle.ctrl_read() if r != CM(0xFE): return False return True def establishLink(self, tracker): if self.dongle.establishLinkEx: return self.establishLinkEx(tracker) self.dongle.ctrl_write(CM(6, tracker.id + [tracker.addrType] + i2lsba(tracker.serviceUUID, 2))) d = self.dongle.ctrl_read() if d == CM(0xff, [2, 3]): # Our detection based on the dongle version is not perfect :( logger.warning("Older tracker %d.%d also needs EstablishLinkEx", self.dongle.major, self.dongle.minor) self.dongle.establishLinkEx = True return self.establishLinkEx(tracker) elif not isStatus(d, 'EstablishLink'): return False d = self.dongle.ctrl_read(5000) if d != CM(4, [0]): logger.error('Unexpected message: %s', d) return False # established, waiting for service discovery # - This one takes long if not isStatus(self.dongle.ctrl_read(8000), 'GAP_LINK_ESTABLISHED_EVENT'): return False # This one can also take some time (Charge tracker) d = self.dongle.ctrl_read(5000) if d != CM(7): logger.error('Unexpected 2nd message: %s', d) return False return True def establishLinkEx(self, tracker): """ First heard from in #236 """ nums = [6, 6, 0, 200] # Looks familiar ? data = tracker.id + [tracker.addrType] for n in nums: data.extend(i2lsba(n, 2)) self.dongle.ctrl_write(CM(0x12, data)) if not isStatus(self.dongle.ctrl_read(), 'EstablishLinkEx'): return False d = self.dongle.ctrl_read(5000) if d != CM(4, [0]): logger.error('Unexpected message: %s', d) return False if not isStatus(self.dongle.ctrl_read(), 'GAP_LINK_ESTABLISHED_EVENT'): return False d = self.dongle.ctrl_read() if d != CM(7): logger.error('Unexpected 2nd message: %s', d) return False return True def toggleTxPipe(self, on): """ `on` is a boolean that dictate the status of the pipe :returns: a boolean about the successful execution """ self.dongle.ctrl_write(CM(8, [int(on)])) d = self.dongle.data_read(5000) return d == DM([0xc0, 0xb]) def initializeAirlink(self, tracker=None): """ :returns: a boolean about the successful execution """ nums = [10, 6, 6, 0, 200] #nums = [1, 8, 16, 0, 200] #nums = [1034, 6, 6, 0, 200] data = [] for n in nums: data.extend(i2lsba(n, 2)) #data = data + [1] self.dongle.data_write(DM([0xc0, 0xa] + data)) if not self.dongle.establishLinkEx: # Not necessary when using establishLinkEx d = self.dongle.ctrl_read(10000) if d != CM(6, data[-6:]): logger.error("Unexpected message: %s != %s", d, CM(6, data[-6:])) return False d = self.dongle.data_read() if d is None: return False if d.data[:2] != [0xc0, 0x14]: logger.error("Wrong header: %s", a2x(d.data[:2])) return False if (tracker is not None) and (d.data[6:12] != tracker.id): logger.error("Connected to wrong tracker: %s", a2x(d.data[6:12])) return False logger.debug("Connection established: %d, %d", a2lsbi(d.data[2:4]), a2lsbi(d.data[4:6])) return True def displayCode(self): """ :returns: a boolean about the successful execution """ logger.debug('Displaying code on tracker') self.dongle.data_write(DM([0xc0, 6])) r = self.dongle.data_read() return (r is not None) and (r.data == [0xc0, 2]) def getDump(self, dumptype=MEGADUMP): """ :returns: a `Dump` object or None """ logger.debug('Getting dump type %d', dumptype) # begin dump of appropriate type self.dongle.data_write(DM([0xc0, 0x10, dumptype])) r = self.dongle.data_read() if r and (r.data[:3] != [0xc0, 0x41, dumptype]): logger.error("Tracker did not acknowledged the dump type: %s", r) return None dump = Dump(dumptype) # Retrieve the dump d = self.dongle.data_read() if d is None: return None dump.add(d.data) while d.data[0] != 0xc0: d = self.dongle.data_read() if d is None: return None dump.add(d.data) # Analyse the dump if not dump.isValid(): logger.error('Dump not valid') return None logger.debug("Dump done, length %d, transportCRC=0x%04x, esc1=0x%02x," " esc2=0x%02x", dump.len, dump.crc.final(), dump.esc[0], dump.esc[1]) return dump def uploadResponse(self, response): """ 4 and 6 are magic values here ... :returns: a boolean about the success of the operation. """ dumptype = 4 # ??? self.dongle.data_write(DM([0xc0, 0x24, dumptype] + i2lsba(len(response), 6))) d = self.dongle.data_read() if d != DM([0xc0, 0x12, dumptype, 0, 0]): logger.error("Tracker did not acknowledged upload type: %s", d) return False CHUNK_LEN = 20 response = DumpResponse(response, CHUNK_LEN) for i, chunk in enumerate(response):#range(0, len(response), CHUNK_LEN): self.dongle.data_write(DM(chunk)) # This one can also take some time (Charge HR tracker) d = self.dongle.data_read(20000) expected = DM([0xc0, 0x13, (((i+1) % 16) << 4) + dumptype, 0, 0]) if d != expected: logger.error("Wrong sequence number: %s, expected: %s", d, expected) return False self.dongle.data_write(DM([0xc0, 2])) # Next one can be very long. He is probably erasing the memory there d = self.dongle.data_read(60000) if d != DM([0xc0, 2]): logger.error("Unexpected answer from tracker: %s", d) return False return True def terminateAirlink(self): """ contrary to ``initializeAirlink`` """ self.dongle.data_write(DM([0xc0, 1])) d = self.dongle.data_read() if d != DM([0xc0, 1]): return False return True def ceaseLink(self): """ contrary to ``establishLink`` """ self.dongle.ctrl_write(CM(7)) if not isStatus(self.dongle.ctrl_read(5000), 'TerminateLink'): return False d = self.dongle.ctrl_read(3000) if (d is None) or (d.INS != 5): # Payload can be either 0x16 or 0x08 return False if not isStatus(self.dongle.ctrl_read(), 'GAP_LINK_TERMINATED_EVENT'): return False if not isStatus(self.dongle.ctrl_read()): # This one doesn't always return '22' return False return True benallard-galileo-f0ebc19c748d/galileo/ui.py0000644000000000000000000001753312767450640017072 0ustar 00000000000000"""\ This is where to look for for all user interaction stuff ... """ import logging import sys logger = logging.getLogger(__name__) try: from html.parser import HTMLParser except ImportError: # Python2 from HTMLParser import HTMLParser try: # Override the input from python2 with raw_input input = raw_input except NameError: pass class Form(object): def __init__(self): self.fields = set() self.submit = None def addField(self, field): self.fields.add(field) def commonFields(self, answer, withValues=True): res = 0 for field in self.fields: if field.name in answer: if withValues: if field.value is not None and field.value == answer[field.name]: res += 1 else: res += 1 return res def takeValuesFromAnswer(self, answer): """\ Transfer the answers from the config to the form """ for field in self.fields: field.value = answer.get(field.name, field.value) if (field.name in answer) and (field.type == 'submit'): self.submit = field.name def asXML(self): """\ Return the XML tuples. The trick is: THere can be only one 'submit' """ res = [] for field in self.fields: if field.type == 'submit': if self.submit != field.name: continue res.append(field.asXMLParam()) return res def __str__(self): return ', '.join(str(f) for f in self.fields) __repr__ = __str__ # To get it printed def asDict(self): """ for comparison in the test suites """ return dict((f.name, f.value) for f in self.fields) class FormField(object): def __init__(self, name, type='text', value=None, **kw): self.name = name self.type = type self.value = value def asXMLParam(self): return ('param', {'name': self.name}, [], self.value) def __str__(self): return '%r: %r' % (self.name, self.value) class FormExtractor(HTMLParser): """ This read a whole html page and extract the forms """ def __init__(self): self.forms = [] self.curForm = None self.curSelect = None HTMLParser.__init__(self) def handle_starttag(self, tag, attrs): attrs = dict(attrs) if tag == 'form': self.curForm = Form() if tag == 'input': if 'name' in attrs: if self.curForm is None: # In case the input happen outside of a form, just create # one, and adds it immediatly f = Form() f.addField(FormField(**attrs)) self.forms.append(f) else: self.curForm.addField(FormField(**attrs)) if tag == 'select': self.curSelect = FormField(type='select', **attrs) if tag == 'option' and 'selected' in attrs: self.curSelect.value = attrs['value'] def handle_endtag(self, tag): if tag == 'form': self.forms.append(self.curForm) self.curForm = None if tag == 'select': self.curForm.addField(self.curSelect) self.curSelect = None def handle_data(self, data): pass class BaseUI(object): """\ This is the base of all ui classes, it provides an interface and handy methods """ def request(self, action, client_display): raise NotImplementedError class MissingConfigError(Exception): def __init__(self, action, forms): self.action = action self.forms = forms def __str__(self): s = ["The server is asking a question to which I don't know any" " answer.",] s.append("Please add the section '%s' in the galileorc configuration" " file under 'hardcoded-ui'" % self.action) s.append("Under this section, you should add the answer for one of the" " following forms:") for f in self.forms: s.append(" - %s" % f.asDict()) s.append("To help you decide, you can run the pairing process with the" " `--debug` command line switch,") s.append("this will print the HTML code from which the questions have" " been extracted.") return '\n'.join(s) class HardCodedUI(BaseUI): """\ This ui class doesn't show anything to the user and takes its answers from a list of hard-coded ones """ def __init__(self, answers): self.answers = answers def request(self, action, html): if html.startswith(''): html = html[len('')] fe = FormExtractor() fe.feed(html) if action not in self.answers: logger.error("No answers provided for '%s'" % action) logger.info("I only know about %s" % self.answers.keys()) raise MissingConfigError(action, fe.forms) answer = self.answers[action] # Figure out which of the form we should fill goodForm = None if len(fe.forms) == 1: # Only one there, no need to search for the correct one ... goodForm = fe.forms[0] else: # We need to find the one that match the most our answers max = 0 for form in fe.forms: v = form.commonFields(answer) if v > max: goodForm = form max = v if max == 0: # Not found, search again, less picky for form in fe.forms: v = form.commonFields(answer, False) if v > max: goodForm = form max = v if goodForm is None: raise ValueError('no answer found') goodForm.takeValuesFromAnswer(answer) return goodForm.asXML() def query_yes_no(question, default="y"): """Ask a yes/no question via raw_input() and return their answer. "question" is a string that is presented to the user. "default" is the presumed answer if the user just hits . It must be "yes" (the default), "no" or None (meaning an answer is required of the user). The "answer" return value is one of True or False. This is from http://stackoverflow.com/a/3041990/1182619 Itself from http://code.activestate.com/recipes/577058/ """ valid = {"yes":True, "y":True, "ye":True, "no":False, "n":False} if default is None: prompt = " [y/n] " elif valid.get(default, False): prompt = " [Y/n] " elif not valid.get(default, True): prompt = " [y/N] " else: raise ValueError("invalid default answer: '%s'" % default) while True: sys.stdout.write(question + prompt) choice = input().lower() if default is not None and choice == '': return valid[default] elif choice in valid: return valid[choice] else: sys.stdout.write("Please respond with 'yes' or 'no' "\ "(or 'y' or 'n').\n") class InteractiveUI(HardCodedUI): """ We can't avoid asking the user to type what's written on the dongle """ def request(self, action, html): if action == 'requestSecret': return self.handle_requestSecret() return HardCodedUI.request(self, action, html) def handle_requestSecret(self): if not query_yes_no("Do you see a number ?"): return [('param', {'name': 'secret'}, [], ''), ('param', {'name': 'tryOther'}, [], 'TRY_OTHER')] sys.stdout.write("Type here the number you see:") secret = input() return [('param', {'name': 'secret'}, [], secret)] benallard-galileo-f0ebc19c748d/galileo/utils.py0000644000000000000000000000265412767450640017613 0ustar 00000000000000"""\ We internally use array of int as data representation, those routines translate them to one or the other format """ import sys def a2x(a, delim=' '): """ array to string of hexa delim is the delimiter between the hexa """ return delim.join('%02X' % x for x in a) def x2a(hexstr): """ String of hex a to array """ return [int(x, 16) for x in hexstr.split(' ')] def a2s(a, toPrint=True): """ array to string toPrint indicates that the resulting string is to be printed (stop at the first \0) """ s = [] for c in a: if toPrint and (c == 0): break s.append(chr(c)) return ''.join(s) def a2b(a): """ array to `bytes` """ if sys.version_info > (3, 0): return bytes(a) return a2s(a, False) def a2lsbi(array): """ array to int (LSB first) """ integer = 0 for i in range(len(array) - 1, -1, -1): integer *= 256 integer += array[i] return integer def a2msbi(array): """ array to int (MSB first) """ integer = 0 for i in range(len(array)): integer *= 256 integer += array[i] return integer def i2lsba(value, width): """ int to array (LSB first) """ a = [0] * width for i in range(width): a[i] = (value >> (i*8)) & 0xff return a def s2a(s): """ string to array """ if isinstance(s, str): return [ord(c) for c in s] return [c for c in s] benallard-galileo-f0ebc19c748d/galileorc.sample0000644000000000000000000000130412767450640017620 0ustar 00000000000000 # -*- mode: yaml; -*- # Default settings for galileo.py. Settings may be changed here or # overridden using command-line switches to galileo.py. # if in daemon mode, delay between sync runs # specified in milliseconds daemon-period: 15000 # keep dump files keep-dumps: true # upload data to Fitbit do-upload: true # directory to store the dumps dump-dir: ~/.galileo # logging (default/verbose/debug) logging: verbose # synchronize even if trackers were recently synchronized force-sync: false # trackers to include include: - 123456789ABC - 9876543210AB - '112233445566' # tracker id composed of only numbers need to be quoted # trackers to exclude exclude: - AABBCCDDEEFF - 881144BB1234 benallard-galileo-f0ebc19c748d/run0000755000000000000000000000007512767450640015212 0ustar 00000000000000#!/usr/bin/env python from galileo.main import main main() benallard-galileo-f0ebc19c748d/setup.py0000755000000000000000000000510712767450640016176 0ustar 00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- import re from io import open try: from setuptools import setup, find_packages, Command except ImportError: import distribute_setup distribute_setup.use_setuptools() from setuptools import setup from galileo import __version__ class CheckVersion(Command): """ Check that the version in the docs is the correct one """ description = "Check the version consistency" user_options = [] def initialize_options(self): """init options""" def finalize_options(self): """finalize options""" def run(self): readme_re = re.compile(r'^:version:\s+' + __version__ + r'\s*$', re.MULTILINE | re.IGNORECASE) man_re = re.compile(r'^\.TH.+[\s"]+' + __version__ + r'[\s"]+', re.MULTILINE | re.IGNORECASE) for filename, regex in ( ('README.txt', readme_re), ('doc/galileo.1', man_re), ('doc/galileorc.5', man_re)): with open(filename) as f: content = f.read() if regex.search(content) is None: raise ValueError('file %s mention the wrong version' % filename) with open('README.txt', encoding='utf8') as file: long_description = file.read() setup( name="galileo", version=__version__, description="Utility to securely synchronize a Fitbit tracker with the" " Fitbit server", long_description=long_description, author="Benoît Allard", author_email="benoit.allard@gmx.de", url="https://bitbucket.org/benallard/galileo", platforms=['any'], keywords=['fitbit', 'synchronize', 'health', 'tracker'], license="LGPL", install_requires=[ "requests", "pyusb>=1a"], # version 1a doesn't exists, but is smaller than 1.0.0a2 test_suite="tests", classifiers=[ 'Development Status :: 5 - Production/Stable', 'License :: OSI Approved :: GNU Lesser General Public License v3 or' ' later (LGPLv3+)', 'Environment :: Console', 'Topic :: Utilities', 'Topic :: Internet', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', ], packages=find_packages(exclude=["tests"]), entry_points={ 'console_scripts': [ 'galileo = galileo.main:main' ], }, cmdclass={ 'checkversion': CheckVersion, }, ) benallard-galileo-f0ebc19c748d/tests/__init__.py0000644000000000000000000000000012767450640017717 0ustar 00000000000000benallard-galileo-f0ebc19c748d/tests/testCRC.py0000644000000000000000000000110012767450640017471 0ustar 00000000000000import unittest from galileo.dump import CRC16 class testCRC(unittest.TestCase): """ CRC unit tests """ def test_XMODEM_123456789(self): # Default values, used by Fitbit crc = CRC16(0x1021, True, 0x0000, 0x0000) a = [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39] crc.update(a) self.assertEqual(crc.final(), 0x31c3) def test_XMODEM_two_parts(self): crc = CRC16() crc.update([0x31, 0x32, 0x33, 0x34, 0x35]) crc.update([0x36, 0x37, 0x38, 0x39]) self.assertEqual(crc.final(), 0x31c3) benallard-galileo-f0ebc19c748d/tests/testConfig.py0000644000000000000000000000461612767450640020306 0ustar 00000000000000import unittest from galileo.config import Config class MyTracker(object): def __init__(self, id, syncedRecently): self.id = id self.syncedRecently = syncedRecently class MyParam(object): def __init__(self, name, value): self.varName = name self.default = value P=MyParam class testShouldSkip(unittest.TestCase): def testRecentForce(self): t = MyTracker([42], True) c = Config([P('forceSync', True), P('includeTrackers', None), P('excludeTrackers', set())]) self.assertFalse(c.shouldSkip(t)) def testRecentNotForce(self): t = MyTracker([42], True) c = Config([P('forceSync', False), P('includeTrackers', None), P('excludeTrackers', set())]) self.assertTrue(c.shouldSkip(t)) def testIncludeNotExclude(self): t = MyTracker([0x42], False) c = Config([P('forceSync', False), P('includeTrackers', set(['42'])), P('excludeTrackers', set())]) self.assertFalse(c.shouldSkip(t)) def testIncludeNoneExclude(self): t = MyTracker([0x42], False) c = Config([P('forceSync', False), P('includeTrackers', None), P('excludeTrackers', set(['42']))]) self.assertTrue(c.shouldSkip(t)) def testNotIncludeExclude(self): t = MyTracker([0x42], False) c = Config([P('forceSync', False), P('includeTrackers', set(['21'])), P('excludeTrackers', set(['42']))]) self.assertTrue(c.shouldSkip(t)) def testIncludeExclude(self): t = MyTracker([0x42], False) c = Config([P('forceSync', False), P('includeTrackers', set(['42'])), P('excludeTrackers', set(['42']))]) self.assertTrue(c.shouldSkip(t)) def testIncludeNoneNotExclude(self): t = MyTracker([0x42], False) c = Config([P('forceSync', False), P('includeTrackers', None), P('excludeTrackers', set())]) self.assertFalse(c.shouldSkip(t)) def testNotIncludeNotExclude(self): t = MyTracker([0x42], False) c = Config([P('forceSync', False), P('includeTrackers', set(['21'])), P('excludeTrackers', set())]) self.assertTrue(c.shouldSkip(t)) benallard-galileo-f0ebc19c748d/tests/testDataRing.py0000644000000000000000000000322012767450640020560 0ustar 00000000000000import unittest from galileo.dongle import DataRing class testRing(unittest.TestCase): def testEmpty(self): r = DataRing(5) self.assertEqual([], r.getData()) self.assertTrue(r.empty) self.assertFalse(r.full) def testCapaNull(self): r = DataRing(0) r.add(5) self.assertEqual([], r.getData()) self.assertTrue(r.empty) self.assertTrue(r.full) def testOneElement(self): r = DataRing(10) r.add('data') self.assertEqual(['data'], r.getData()) self.assertFalse(r.empty) self.assertFalse(r.full) self.assertEqual(r.queue + 1, r.head) self.assertEqual(1, r.fill) def testTwoElement(self): r = DataRing(10) r.add('data1') r.add('data2') self.assertFalse(r.empty) self.assertEqual(['data1', 'data2'], r.getData()) self.assertEqual(2, r.fill) def testThreeElement(self): r = DataRing(10) r.add('data1') r.add('data2') r.add('data3') self.assertFalse(r.empty) self.assertEqual(['data1', 'data2', 'data3'], r.getData()) self.assertEqual(3, r.fill) def testOverflow(self): r = DataRing(2) self.assertFalse(r.full) self.assertEqual(0, r.fill) r.add('data1') self.assertFalse(r.full) self.assertEqual(1, r.fill) r.add('data2') self.assertTrue(r.full) self.assertEqual(2, r.fill) r.add('data3') self.assertFalse(r.empty) self.assertTrue(r.full) self.assertEqual(2, r.fill) self.assertEqual(['data2', 'data3'], r.getData()) benallard-galileo-f0ebc19c748d/tests/testDongle.py0000644000000000000000000000531112767450640020302 0ustar 00000000000000import errno import unittest import galileo.dongle from galileo.dongle import isStatus, FitBitDongle, CM, DM, isATimeout USBError = galileo.dongle.usb.core.USBError class MyCM(object): def __init__(self, ins, payload): self.INS = ins self.payload = payload class testisStatus(unittest.TestCase): def testNotAStatus(self): self.assertFalse(isStatus(MyCM(3, []))) def testIsaStatus(self): self.assertTrue(isStatus(MyCM(1, []))) def testEquality(self): self.assertTrue(isStatus(MyCM(1, [0x61, 0x62, 0x63, 0x64 , 0]), 'abcd')) def testStartsWith(self): self.assertTrue(isStatus(MyCM(1, [0x61, 0x62, 0x63, 0x64 , 0]), 'ab')) class testCM(unittest.TestCase): r2 = list(range(2)) r5 = list(range(5)) def testEquals(self): self.assertTrue(CM(8) == CM(8)) self.assertTrue(CM(5) == CM(5, [])) self.assertTrue(CM(2, self.r5), CM(2, self.r5)) self.assertEqual(CM(8), CM(8)) self.assertEqual(CM(5), CM(5, [])) self.assertEqual(CM(2, self.r5), CM(2, self.r5)) def testNotEquals(self): self.assertFalse(CM(7) == CM(8)) self.assertFalse(CM(9) == CM(9, [5])) self.assertFalse(CM(3, self.r2) == CM(3, self.r5)) self.assertFalse(None == CM(3, self.r5)) class testDM(unittest.TestCase): def testEquals(self): self.assertTrue(DM(range(3)) == DM(range(3))) self.assertEqual(DM(range(8)), DM(range(8))) def testNotEquals(self): self.assertFalse(DM([87]) == DM([42])) self.assertFalse(DM(range(2)) == DM(range(5))) self.assertFalse(None == DM(range(5))) class MyDev(object): """ Minimal object to reproduce issue#75 """ def is_kernel_driver_active(self, a): raise NotImplementedError() def get_active_configuration(self): return {(0,0): None, (1,0): None} def set_configuration(self): pass def reset(self): pass class testDongle(unittest.TestCase): def testNIE(self): def myFind(*args, **kwargs): return MyDev() galileo.dongle.usb.core.find = myFind d = FitBitDongle(0) d.setup() class testisATimeout(unittest.TestCase): def testErrnoTIMEOUT(self): """ usb.core.USBError: [Errno 110] Operation timed out """ self.assertTrue(isATimeout(USBError('Operation timed out', errno=errno.ETIMEDOUT))) def testpyusb1a2(self): """\ issue#17 usb.core.USBError: Operation timed out """ self.assertTrue(isATimeout(IOError('Operation timed out'))) def testlibusb0(self): """\ issue#82 usb.core.USBError: [Errno None] Connection timed out """ self.assertTrue(isATimeout(USBError('Connection timed out'))) benallard-galileo-f0ebc19c748d/tests/testDump.py0000644000000000000000000000422512767450640020002 0ustar 00000000000000import unittest from galileo.dump import Dump class testDump(unittest.TestCase): def testEmptyNonValid(self): d = Dump(6) self.assertFalse(d.isValid()) def testAddIncreasesLen(self): d = Dump(5) self.assertEqual(d.len, 0) d.add(range(10)) self.assertEqual(d.len, 10) def testFooterIsSet(self): d = Dump(0) self.assertEqual(d.footer, []) d.add([0xc0] + list(range(5))) self.assertEqual(d.len, 0) self.assertEqual(d.footer, [0xc0] + list(range(5))) def testOnlyFooterInvalid(self): """ A dump with only a footer is an invalid dump """ d = Dump(0) d.add([0xc0] + list(range(5))) self.assertFalse(d.isValid()) def testEsc1(self): d = Dump(0) self.assertEqual(d.esc[0], 0) d.add([0xdb, 0xdc]) self.assertEqual(d.len, 1) self.assertEqual(d.esc[0], 1) self.assertEqual(d.data, [0xc0]) def testEsc2(self): d = Dump(0) self.assertEqual(d.esc[1], 0) d.add([0xdb, 0xdd]) self.assertEqual(d.len, 1) self.assertEqual(d.esc[1], 1) self.assertEqual(d.data, [0xdb]) def testToBase64(self): d = Dump(0) d.add(range(10)) d.add([0xc0] + list(range(8))) self.assertEqual(d.toBase64(), 'AAECAwQFBgcICcAAAQIDBAUGBw==') def testNonValidDataType(self): d = Dump(0) d.add(range(10)) d.add([0xc0]+[0, 3]) self.assertFalse(d.isValid()) def testNonValidCRC(self): d = Dump(0) d.add(range(10)) d.add([0xc0]+[0, 0, 0, 0]) self.assertFalse(d.isValid()) def testNonValidLen(self): d = Dump(0) d.add(range(10)) d.add([0xc0]+[0, 0, 0x78, 0x23, 0, 0]) self.assertFalse(d.isValid()) def testValid(self): d = Dump(0) d.add(range(10)) d.add([0xc0]+[0, 0, 0x78, 0x23, 10, 0]) self.assertTrue(d.isValid()) def testHugeDump(self): # issue 177 d = Dump(0) d.add([5] * 71318) d.add([0xc0]+[0, 0, 0x44, 0x95, 0x96, 0x16, 0x01, 0x00]) self.assertTrue(d.isValid()) benallard-galileo-f0ebc19c748d/tests/testFitbitClient.py0000644000000000000000000004032112767450640021452 0ustar 00000000000000import unittest from galileo.tracker import FitbitClient class MyDM(object): def __init__(self, data): self.data = data def __str__(self): return str(self.data) class MyCM(object): def __init__(self, data): self.len = data[0] self.INS = data[1] self.payload = data[2:] def asList(self): return [self.len, self.INS] + self.payload def __str__(self): return str(self.asList()) class MyDongle(object): def __init__(self, responses): self.responses = responses self.idx = 0 self.establishLinkEx = False def read(self, ctrl): response = self.responses[self.idx] self.idx += 1 if not response: return None if ctrl: return MyCM(list(response)) else: return MyDM(list(response)) def ctrl_write(self, *args): pass def ctrl_read(self, *args): return self.read(True) def data_read(self, *args): return self.read(False) def data_write(self, *args): pass def setVersion(self, M, m): self.v = (M, m) class MyDongleWithTimeout(MyDongle): """ A Dongle that starts timeouting at threshold """ def __init__(self, data, threshold): MyDongle.__init__(self, data[:threshold] + [()] * (len(data) - threshold)) class MyUUID(object): @property def int(self): return 0 class MyTracker(object): pass GOOD_SCENARIO = [ # CancelDiscovery (0x20, 1, 0x43, 0x61, 0x6E, 0x63, 0x65, 0x6C, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0), # TerminateLink (0x20, 1, 0x54, 0x65, 0x72, 0x6D, 0x69, 0x6E, 0x61, 0x74, 0x65, 0x4C, 0x69, 0x6E, 0x6B, 0), (), (0x15, 8, 1, 1, 0x6F, 0x7B, 0xAD, 0x29, 0x6A, 0xBC, 0x74, 0x09, 0, 0x20, 0, 0, 0xFF, 0xE7, 3, 0, 1), (0x20, 1, 0x53, 0x74, 0x61, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0), (0x13, 3, 0,0,42,0,0,0, 1, 0x80, 2, 6,4, 0,0,0,0,0,0), (3, 2, 1), # CancelDiscovery (0x20, 1, 0x43, 0x61, 0x6E, 0x63, 0x65, 0x6C, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0), (0x20, 1, 0x45, 0x73, 0x74, 0x61, 0x62, 0x6C, 0x69, 0x73, 0x68, 0x4C, 0x69, 0x6E, 0x6B, 0), (3, 4, 0), (0x20, 1, 0x47, 0x41, 0x50, 0x5F, 0x4C, 0x49, 0x4E, 0x4B, 0x5F, 0x45, 0x53, 0x54, 0x41, 0x42, 0x4C, 0x49, 0x53, 0x48, 0x45, 0x44, 0x5F, 0x45, 0x56, 0x45, 0x4E, 0x54, 0), (2, 7), (0xc0, 0xb), (8, 6, 6, 0, 0, 0, 0xc8, 0), (0xc0, 0x14, 0xc,1, 0,0, 0,0,42,0,0,0), # getDump (0xc0, 0x41, 0xd), (0x26, 2, 0, 0, 0, 0, 0), (0xc0, 0,0xd,0x93,0x44,7, 0), #response (0xc0, 0x12, 4, 0, 0), (0xc0, 0x13, 0x14, 0, 0), (0xc0, 0x13, 0x24, 0, 0), (0xc0, 2), (0xc0, 1), (0xc0, 0xb), (0x20, 1, 0x54, 0x65, 0x72, 0x6D, 0x69, 0x6E, 0x61, 0x74, 0x65, 0x4C, 0x69, 0x6E, 0x6B, 0), (3, 5, 0x16, 0), (0x20, 1, 0x47, 0x41, 0x50, 0x5F, 0x4C, 0x49, 0x4E, 0x4B, 0x5F, 0x54, 0x45, 0x52, 0x4D, 0x49, 0x4E, 0x41, 0x54, 0x45, 0x44, 0x5F, 0x45, 0x56, 0x45, 0x4E, 0x54, 0), (0x20, 1, 0x32, 0x32, 0), ] SURGE_SCENARIO = [ (0x16, 0x08, 0x02, 0x05, 0x05, 0xDF, 0x5E, 0x5E, 0xB8, 0xF4, 0x74, 0x04, 0x00, 0x20, 0x00, 0x00, 0xFF, 0xE7, 0x01, 0x00, 0x02, 0x00), # CancelDiscovery (0x20, 1, 0x43, 0x61, 0x6E, 0x63, 0x65, 0x6C, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0), # TerminateLink (0x20, 1, 0x54, 0x65, 0x72, 0x6D, 0x69, 0x6E, 0x61, 0x74, 0x65, 0x4C, 0x69, 0x6E, 0x6B, 0), (0x16, 0x08, 0x02, 0x05, 0x05, 0xDF, 0x5E, 0x5E, 0xB8, 0xF4, 0x74, 0x04, 0x00, 0x20, 0x00, 0x00, 0xFF, 0xE7, 0x01, 0x00, 0x02, 0x00), ] class testScenarii(unittest.TestCase): def testOk(self): d = MyDongle(GOOD_SCENARIO) c = FitbitClient(d) self.assertTrue(c.disconnect()) self.assertTrue(c.getDongleInfo()) ts = [t for t in c.discover(MyUUID())] self.assertEqual(1, len(ts)) self.assertEqual(ts[0].id, [0,0,42,0,0,0]) self.assertTrue(c.establishLink(ts[0])) self.assertTrue(c.toggleTxPipe(True)) self.assertTrue(c.initializeAirlink(ts[0])) dump = c.getDump() self.assertFalse(dump is None) self.assertEqual(dump.data, [0x26, 2, 0,0,0,0,0]) self.assertTrue(c.uploadResponse((0x26, 2, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0))) self.assertTrue(c.terminateAirlink()) self.assertTrue(c.toggleTxPipe(False)) self.assertTrue(c.ceaseLink()) def testTimeout(self): # the test will have to be re-writen if the scenario changes self.assertEqual(28, len(GOOD_SCENARIO)) for i in range(len(GOOD_SCENARIO) + 1): d = MyDongleWithTimeout(GOOD_SCENARIO, i) c = FitbitClient(d) if i < 1: self.assertFalse(c.disconnect(), i) continue self.assertTrue(c.disconnect()) if i < 4: self.assertFalse(c.getDongleInfo(), i) continue self.assertTrue(c.getDongleInfo()) ts = [t for t in c.discover(MyUUID())] if i < 6: self.assertEqual([], ts, i) continue self.assertEqual(1, len(ts), i) self.assertEqual(ts[0].id, [0,0,42,0,0,0]) if i < 12: self.assertFalse(c.establishLink(ts[0]), i) continue self.assertTrue(c.establishLink(ts[0]), i) if i < 13: self.assertFalse(c.toggleTxPipe(True), i) continue self.assertTrue(c.toggleTxPipe(True)) if i < 15: self.assertFalse(c.initializeAirlink(ts[0])) continue self.assertTrue(c.initializeAirlink(ts[0])) if i < 18: self.assertEqual(None, c.getDump()) continue dump = c.getDump() self.assertFalse(dump is None) self.assertEqual(dump.data, [0x26, 2, 0,0,0,0,0]) if i < 22: self.assertFalse(c.uploadResponse((0x26, 2, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0))) continue self.assertTrue(c.uploadResponse((0x26, 2, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0))) if i < 23: self.assertFalse(c.terminateAirlink()) continue self.assertTrue(c.terminateAirlink()) if i < 24: self.assertFalse(c.toggleTxPipe(False)) continue self.assertTrue(c.toggleTxPipe(False)) if i < 28: self.assertFalse(c.ceaseLink()) continue self.assertTrue(c.ceaseLink()) self.assertEqual(len(GOOD_SCENARIO), i) class testDiscover(unittest.TestCase): def testNoTracker(self): d = MyDongle([(0x20, 1, 0x53, 0x74, 0x61, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0 ), (3, 2, 0), (0x20, 1, 0x43, 0x61, 0x6E, 0x63, 0x65, 0x6C, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0), ]) c = FitbitClient(d) ts = [t for t in c.discover(MyUUID())] self.assertEqual(len(ts), 0) def testOnetracker(self): d = MyDongle([(0x20, 1, 0x53, 0x74, 0x61, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0 ), (0x13, 3, 0xaa,0xaa,0xaa,0xaa,0xaa,0xaa,1,-30, 2,6,4, 3, 0x2c, 0x31, 0xf6, 0xd8, 0x58), (3, 2, 1), (0x20, 1, 0x43, 0x61, 0x6E, 0x63, 0x65, 0x6C, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0), ]) c = FitbitClient(d) ts = [t for t in c.discover(MyUUID())] self.assertEqual(len(ts), 1) t = ts[0] self.assertEqual(t.id, [0xaa] * 6) def testTwotracker(self): d = MyDongle([(0x20, 1, 0x53, 0x74, 0x61, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0 ), (0x13, 3, 0xaa,0xaa,0xaa,0xaa,0xaa,0xaa,1,-30, 2,6,4, 3, 0x2c, 0x31, 0xf6, 0xd8, 0x58), (0x13, 3, 0xbb,0xbb,0xbb,0xbb,0xbb,0xbb,1,-30, 2,6,4, 3, 0x2c, 0x31, 0xf6, 0xd8, 0x58), (3, 2, 2), (0x20, 1, 0x43, 0x61, 0x6E, 0x63, 0x65, 0x6C, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0), ]) c = FitbitClient(d) ts = [t for t in c.discover(MyUUID())] self.assertEqual(len(ts), 2) t = ts[0] self.assertEqual(t.id, [0xaa] * 6) t = ts[1] self.assertEqual(t.id, [0xbb] * 6) def testTimeout(self): d = MyDongle([(0x20, 1, 0x53, 0x74, 0x61, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0 ), (), ()]) c = FitbitClient(d) ts = [t for t in c.discover(MyUUID())] self.assertEqual(len(ts), 0) def testWrongParams(self): """ Sometime, we get the amount before the Status """ d = MyDongle([(3, 2, 0), (0x20, 1, 0x53, 0x74, 0x61, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0 ), (0x20, 1, 0x43, 0x61, 0x6E, 0x63, 0x65, 0x6C, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79, 0), ]) c = FitbitClient(d) ts = [t for t in c.discover(MyUUID())] self.assertEqual(len(ts), 0) def testIssue96(self): """ Sometime, we don't get payload """ d = MyDongle([(2, 0xa), ()]) c = FitbitClient(d) ts = [t for t in c.discover(MyUUID())] self.assertEqual(len(ts), 0) def testIssue231(self): """ Some weird Status Messages in the middle """ d = MyDongle([(0x20, 1, 0x45, 0x52, 0x52, 0x4F, 0x52, 0x3A, 0x20, 0x50, 0x31, 0x5B, 0x37, 0x3A, 0x31, 0x5D, 0x20, 0x73, 0x68, 0x6F, 0x75, 0x6C, 0x64, 0x20, 0x62, 0x65, 0x20, 0x30), (0x20, 1, 0x33), (0x20, 1, 0x53, 0x74, 0x61, 0x72, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x76, 0x65, 0x72, 0x79), (0x13, 0x03, 0xD2, 0xCD, 0x91, 0xC1, 0x01, 0xF8, 0x01, 0xB6, 0x02, 0x07, 0x06, 0x3E, 0x00, 0x09, 0x4A, 0x00, 0xFB), (3, 2, 1), ()]) c = FitbitClient(d) ts = [t for t in c.discover(MyUUID())] self.assertEqual(len(ts), 1) class testGetDongleInfo(unittest.TestCase): def testIssue136(self): d = MyDongle([(0x20, 1, 0x54, 0x65, 0x72, 0x6D, 0x69, 0x6E, 0x61, 0x74, 0x65, 0x4C, 0x69, 0x6E, 0x6B, 0),]) c = FitbitClient(d) self.assertFalse(c.getDongleInfo()) def testOkOld(self): d = MyDongle([(0x15, 8, 1, 1, 0x6F, 0x7B, 0xAD, 0x29, 0x6A, 0xBC, 0x74, 0x09, 0, 0x20, 0, 0, 0xFF, 0xE7, 3, 0, 1),]) c = FitbitClient(d) self.assertTrue(c.getDongleInfo()) self.assertEqual(d.v, (1,1)) self.assertEqual(d.flashEraseTime, 2420) self.assertEqual(d.firmwareStartAddress, 8192) self.assertEqual(d.firmwareEndAddress, 255999) self.assertEqual(d.ccIC, 1) def testOk(self): d = MyDongle([(0x16, 8, 2, 5, 0x71, 0x59, 0x46, 0x16, 0x4A, 0x54, 0x74, 4, 0, 0x20, 0, 0, 0xFF, 0xE7, 1, 0, 2, 0),]) c = FitbitClient(d) self.assertTrue(c.getDongleInfo()) self.assertEqual(d.v, (2,5)) def testSurgeDongle(self): d = MyDongle([(0x16, 0x08, 0x02, 0x05, 0x05, 0xDF, 0x5E, 0x5E, 0xB8, 0xF4, 0x74, 0x04, 0x00, 0x20, 0x00, 0x00, 0xFF, 0xE7, 0x01, 0x00, 0x02, 0),]) c = FitbitClient(d) self.assertTrue(c.getDongleInfo()) self.assertEqual(d.v, (2,5)) self.assertEqual(d.flashEraseTime, 1140) self.assertEqual(d.firmwareStartAddress, 8192) self.assertEqual(d.firmwareEndAddress, 124927) self.assertEqual(d.ccIC, 2) def testNewerDongle75(self): d = MyDongle([(0x16, 0x08, 0x07, 0x05, 0xA4, 0xA6, 0x69, 0xF3, 0x7B, 0x98, 0x74, 0x04, 0x00, 0x20, 0x00, 0x00, 0xFF, 0xE7, 0x01, 0x00, 0x02, 0x00)]) c = FitbitClient(d) self.assertTrue(c.getDongleInfo()) self.assertEqual(d.v, (7,5)) self.assertEqual(d.flashEraseTime, 1140) self.assertEqual(d.firmwareStartAddress, 8192) self.assertEqual(d.firmwareEndAddress, 124927) self.assertEqual(d.ccIC, 2) class testestablishLink(unittest.TestCase): def testestablishLinkExOk(self): d = MyDongle([(0x20, 1, 0x45, 0x73, 0x74, 0x61, 0x62, 0x6C, 0x69, 0x73, 0x68, 0x4C, 0x69, 0x6E, 0x6B, 0x45, 0x78, 0x20, 0x63, 0x61, 0x6C, 0x6C, 0x65, 0x64, 0x2E, 0x2E, 0x2E, 0x00), (3, 4, 0), (0x20, 1, 0x47, 0x41, 0x50, 0x5F, 0x4C, 0x49, 0x4E, 0x4B, 0x5F, 0x45, 0x53, 0x54, 0x41, 0x42, 0x4C, 0x49, 0x53, 0x48, 0x45, 0x44, 0x5F, 0x45, 0x56, 0x45, 0x4E, 0x54, 0), (2, 7),]) d.establishLinkEx = True c = FitbitClient(d) t = MyTracker() t.id = [0,0,42,0,0,43] t.addrType = 1 self.assertTrue(c.establishLink(t)) def testestablishLinkExNotOk(self): """ When our version test is wrong """ d = MyDongle([(4, 0xff, 2, 3), (0x20, 1, 0x45, 0x73, 0x74, 0x61, 0x62, 0x6C, 0x69, 0x73, 0x68, 0x4C, 0x69, 0x6E, 0x6B, 0x45, 0x78, 0x20, 0x63, 0x61, 0x6C, 0x6C, 0x65, 0x64, 0x2E, 0x2E, 0x2E, 0), (3, 4, 0), (0x20, 1, 0x47, 0x41, 0x50, 0x5F, 0x4C, 0x49, 0x4E, 0x4B, 0x5F, 0x45, 0x53, 0x54, 0x41, 0x42, 0x4C, 0x49, 0x53, 0x48, 0x45, 0x44, 0x5F, 0x45, 0x56, 0x45, 0x4E, 0x54, 0), (2, 7),]) d.major = 169; d.minor=78 c = FitbitClient(d) t = MyTracker() t.id = [0,0,42,0,0,43] t.addrType = 1 t.serviceUUID = 0xa005 self.assertTrue(c.establishLink(t)) # verify the value is set for later tests self.assertTrue(d.establishLinkEx) class testinitAirLink(unittest.TestCase): def testCharge(self): d = MyDongle([(8, 6, 6, 0, 0, 0, 0xc8, 0), (0xc0, 0x14, 0xc,0xa, 0,0, 0,0,42,0,0,0, 0x17,0),]) c = FitbitClient(d) t = MyTracker() t.id = [0,0,42,0,0,0] self.assertTrue(c.initializeAirlink(t)) def testOthers(self): d = MyDongle([(8, 6, 6, 0, 0, 0, 0xc8, 0), (0xc0, 0x14, 0xc,1, 0,0, 0,0,42,0,0,0),]) c = FitbitClient(d) t = MyTracker() t.id = [0,0,42,0,0,0] self.assertTrue(c.initializeAirlink(t)) def testEstablishEx(self): """ When the dongle uses establishEx, he doesn't read back on the ctrl channel """ d = MyDongle([(0xc0, 0x14, 0xc,1, 0,0, 0,0,42,0,0,0),]) d.establishLinkEx = True c = FitbitClient(d) t = MyTracker() t.id = [0,0,42,0,0,0] self.assertTrue(c.initializeAirlink(t)) class testUpload(unittest.TestCase): def testLongMessage(self): """ Validate that the seq number rounds up """ class MyDongle(object): def __init__(self, len): self.i = -1 self.len = len def data_read(self ,*args): self.i += 1 if self.i == 0: return MyDM([0xc0, 0x12, 4, 0, 0]) if self.i < self.len: return MyDM([0xc0, 0x13, (((self.i) % 16) << 4) + 4, 0, 0]) return MyDM([0xc0, 2]) def data_write(self, *args): pass d = MyDongle(20) c = FitbitClient(d) self.assertTrue(c.uploadResponse([0] * 380)) class testDownload(unittest.TestCase): def testPreSurge(self): d = MyDongle([ (0xc0, 0x41, 0xd), (0x26, 2, 0, 0, 0, 0, 0), (0xc0, 0,0xd,0x93,0x44,7, 0)]) c = FitbitClient(d) dump = c.getDump(0xd) self.assertTrue(dump.isValid()) self.assertEqual(dump.data, [38, 2, 0, 0, 0, 0, 0]) self.assertEqual(dump.footer, [192, 0, 13, 147, 68, 7, 0]) def testSurge(self): # This is not completely correct d = MyDongle([ (0xc0, 0x41, 0xd, 0x42, 0xa, 0, 0), (0x26, 2, 0, 0, 0, 0, 0), (0xc0, 0,0xd,0x93,0x44,7, 0)]) c = FitbitClient(d) dump = c.getDump(0xd) self.assertTrue(dump.isValid()) class testSetPowerLevel(unittest.TestCase): def testOk(self): d = MyDongle([(2, 0xfe),]) c = FitbitClient(d) self.assertTrue(c.setPowerLevel(5)) benallard-galileo-f0ebc19c748d/tests/testFormExtractor.py0000644000000000000000000000261612767450640021676 0ustar 00000000000000import unittest from galileo.ui import FormExtractor, FormField class testFormExtractor(unittest.TestCase): def testEasy(self): fe = FormExtractor() fe.feed('
') self.assertEqual(len(fe.forms), 1) self.assertEqual(len(fe.forms[0].fields), 2) self.assertEqual(fe.forms[0].asDict(), {'username':None, 'password':None}) def testOneHidden(self): fe = FormExtractor() fe.feed('
') self.assertEqual(len(fe.forms), 1) self.assertEqual(fe.forms[0].asDict(), {'username': 'User', 'password': None}) def testSelect(self): fe = FormExtractor() fe.feed('
') self.assertEqual(len(fe.forms), 1) self.assertEqual(fe.forms[0].asDict(), {'choice': 'B'}) def testInputOutOfForm(self): """ From the 'done' action """ fe = FormExtractor() fe.feed(u'''''') self.assertEqual(len(fe.forms), 1) self.assertEqual(fe.forms[0].asDict(), {'again': 'Next'}) benallard-galileo-f0ebc19c748d/tests/testGalileoClient.py0000644000000000000000000002477312767450640021622 0ustar 00000000000000import unittest import sys from galileo import __version__ import galileo.net from galileo.net import GalileoClient, SyncError, BackOffException class requestResponse(object): def __init__(self, text, server_version='\n\n'): self.text = """%s%s""" % (server_version, text) def raise_for_status(self): pass class testStatus(unittest.TestCase): def testOk(self): def mypost(url, data, headers): self.assertEqual(url, 'scheme://host:8888/path/to/stuff') self.assertEqual(data.decode('utf-8'), """\ %(id)s%(version)sstatus""" % { 'id': GalileoClient.ID, 'version': __version__}) self.assertEqual(headers['Content-Type'], 'text/xml') return requestResponse('') galileo.net.requests.post = mypost gc = GalileoClient('scheme', 'host', 'path/to/stuff', 8888) gc.requestStatus() def testError(self): def mypost(url, data, headers): self.assertEqual(url, 'h://c:8/p') self.assertEqual(data.decode('utf-8'), """\ %(id)s%(version)sstatus""" % { 'id': GalileoClient.ID, 'version': __version__}) self.assertEqual(headers['Content-Type'], 'text/xml') return requestResponse('Something is Wrong') galileo.net.requests.post = mypost gc = GalileoClient('h', 'c', 'p', 8) self.assertRaises(SyncError, gc.requestStatus) def testBackOff(self): # no support for ``with assertRaises`` in python 2.6 if sys.version_info < (2,7): return def mypost(url, data, headers): self.assertEqual(url, 'h://c:4/p') self.assertEqual(data.decode('utf-8'), """\ %(id)s%(version)sstatus""" % { 'id': GalileoClient.ID, 'version': __version__}) self.assertEqual(headers['Content-Type'], 'text/xml') return requestResponse(""" 1800000 3600000 Server is in maintenance mode. We'll be back soon! """, '') galileo.net.requests.post = mypost gc = GalileoClient('h', 'c', 'p', 4) with self.assertRaises(BackOffException) as cm: gc.requestStatus() e = cm.exception self.assertEqual(e.min, 1800000) self.assertEqual(e.max, 3600000) val = e.getAValue() self.assertTrue(e.min <= val <= e.max) def testStatusRequests082(self): """ Older versions of requests only have ``content`` and no ``text`` """ def mypost(url, data, headers): self.assertEqual(url, 'scheme://host:8888/path/to/stuff') self.assertEqual(data.decode('utf-8'), """\ %(id)s%(version)sstatus""" % { 'id': GalileoClient.ID, 'version': __version__}) self.assertEqual(headers['Content-Type'], 'text/xml') res = requestResponse('') res.content = res.text delattr(res, 'text') return res galileo.net.requests.post = mypost gc = GalileoClient('scheme', 'host', 'path/to/stuff', 8888) gc.requestStatus() class MyDongle(object): def __init__(self, M, m): self.major=M; self.minor=m; self.hasVersion=True class MyMegaDump(object): def __init__(self, b64): self.b64 = b64 def toBase64(self): return self.b64 class testSync(unittest.TestCase): def testOk(self): T_ID = 'abcd' D = MyDongle(0, 0) d = MyMegaDump('YWJjZA==') def mypost(url, data, headers): self.assertEqual(url, 'a://b:0/c') self.assertEqual(data.decode('utf-8'), """\ %(id)s%(version)ssync%(b64dump)s""" % { 'id': GalileoClient.ID, 'version': __version__, 'M': D.major, 'm': D.minor, 't_id': T_ID, 'b64dump': d.toBase64()}) self.assertEqual(headers['Content-Type'], 'text/xml') return requestResponse('ZWZnaA==') galileo.net.requests.post = mypost gc = GalileoClient('a', 'b', 'c', 0) self.assertEqual(gc.sync(D, T_ID, d), [101, 102, 103, 104]) def testNoTracker(self): T_ID = 'aaaabbbb' D = MyDongle(34, 88) d = MyMegaDump('base64Dump') def mypost(url, data, headers): self.assertEqual(url, 'z://y:42/u') self.assertEqual(data.decode('utf-8'), """\ %(id)s%(version)ssync%(b64dump)s""" % { 'id': GalileoClient.ID, 'version': __version__, 'M': D.major, 'm': D.minor, 't_id': T_ID, 'b64dump': d.toBase64()}) self.assertEqual(headers['Content-Type'], 'text/xml') return requestResponse('') galileo.net.requests.post = mypost gc = GalileoClient('z', 'y', 'u', 42) self.assertRaises(SyncError, gc.sync, D, T_ID, d) def testNoData(self): T_ID = 'aaaa' D = MyDongle(-2, 42) d = MyMegaDump('base64Dump') def mypost(url, data, headers): self.assertEqual(url, 'y://t:8000/v') self.assertEqual(data.decode('utf-8'), """\ %(id)s%(version)ssync%(b64dump)s""" % { 'id': GalileoClient.ID, 'version': __version__, 'M': D.major, 'm': D.minor, 't_id': T_ID, 'b64dump': d.toBase64()}) self.assertEqual(headers['Content-Type'], 'text/xml') return requestResponse('') galileo.net.requests.post = mypost gc = GalileoClient('y', 't', 'v', 8000) self.assertRaises(SyncError, gc.sync, D, T_ID, d) def testNotData(self): T_ID = 'aaaabbbbccccdddd' D = MyDongle(-2, 42) d = MyMegaDump('base64Dump') def mypost(url, data, headers): self.assertEqual(url, 'rsync://ssh:22/a/b/c') self.assertEqual(data.decode('utf-8'), """\ %(id)s%(version)ssync%(b64dump)s""" % { 'id': GalileoClient.ID, 'version': __version__, 'M': D.major, 'm': D.minor, 't_id': T_ID, 'b64dump': d.toBase64()}) self.assertEqual(headers['Content-Type'], 'text/xml') return requestResponse('') galileo.net.requests.post = mypost gc = GalileoClient('rsync', 'ssh', 'a/b/c', 22) self.assertRaises(SyncError, gc.sync, D, T_ID, d) def testConnectionError(self): T_ID = 'abcd' D = MyDongle(0, 0) d = MyMegaDump('YWJjZA==') def mypost(url, data, headers): class Reason(object): class Error(object): strerror = '' reason = Error() raise galileo.net.requests.exceptions.ConnectionError(Reason()) galileo.net.requests.post = mypost gc = GalileoClient('a', 'b', 'c', 0) self.assertRaises(SyncError, gc.sync,D, T_ID, d) def testHTTPError(self): # issue147 def mypost(url, data, headers): class Response(object): status_code=500 if galileo.net.requests.__build__ > 0x020000: # Only newer requests exceptions inherit from IOError e = galileo.net.requests.exceptions.HTTPError('bad', response=Response()) else: # older inherit from RuntimeError (no kwargs) e = galileo.net.requests.exceptions.HTTPError('bad') raise e galileo.net.requests.post = mypost T_ID = 'abcd' D = MyDongle(0, 0) d = MyMegaDump('YWJjZA==') gc = GalileoClient('a', 'b', 'c', 0) with self.assertRaises(SyncError) as cm: gc.sync(D, T_ID, d) self.assertEqual(cm.exception.errorstring, 'HTTPError: bad (500)') class testURL(unittest.TestCase): def testWithPort(self): gc = GalileoClient('scheme', 'host', 'path/to/stuff', 8000) self.assertEqual(gc.url, 'scheme://host:8000/path/to/stuff') def testHTTPPort(self): gc = GalileoClient('http', 'h', 'a/b/c') self.assertEqual(gc.url, 'http://h:80/a/b/c') def testHTTPSPort(self): gc = GalileoClient('https', 'h', 'a/b/c') self.assertEqual(gc.url, 'https://h:443/a/b/c') def testUnknownPort(self): # no support for ``with assertRaises`` in python 2.6 if sys.version_info < (2,7): return gc = GalileoClient('blah', 'h', 'a') with self.assertRaises(KeyError): gc.url benallard-galileo-f0ebc19c748d/tests/testNetUtils.py0000644000000000000000000000601712767450640020645 0ustar 00000000000000import unittest import xml.etree.ElementTree as ET from io import BytesIO from galileo.net import toXML, tuplesToXML, XMLToTuple class testtoXML(unittest.TestCase): def _testEqual(self, xml, xmlStr): tree = ET.ElementTree(xml) f = BytesIO() tree.write(f) self.assertEqual(f.getvalue().decode('utf-8'), xmlStr) f.close() def testSimple(self): self._testEqual(toXML('elem'), '') def testSimpleWithAttrs(self): self._testEqual(toXML('elem', {'attr1': 'val', 'attr2': 'val'}), '') def testSimpleWithBody(self): self._testEqual(toXML('elem', body="body"), 'body') def testSimpleWithChilds(self): self._testEqual(toXML('parent', childs=[('child1',), ('child2',)]), '') def testFull(self): self._testEqual(toXML('parent', {'a':'c'}, [('c',), ('c', {}, [], 'b')], 'b'), 'bb') def testOnetuplesToXML(self): xmls = list(tuplesToXML(('p',{'a':'a'}, [], 'b'))) self.assertEqual(len(xmls), 1) self._testEqual(xmls[0], '

b

') def testOnetuplesToXML2(self): xmls = list(tuplesToXML([('p',{'a':'a'}, [], 'b')])) self.assertEqual(len(xmls), 1) self._testEqual(xmls[0], '

b

') def testMultipletuplesToXML(self): xmls = list(tuplesToXML([('p',{'a':'a'}, [], 'b'), ('p'), ('p', {}, [], 'b')])) self.assertEqual(len(xmls), 3) self._testEqual(xmls[0], '

b

') self._testEqual(xmls[1], '

') self._testEqual(xmls[2], '

b

') class testtoTuple(unittest.TestCase): def _testEqual(self, xmlStr, tpls): tpl = XMLToTuple(ET.fromstring(xmlStr)) self.assertEqual(tpl, tpls) def testSimple(self): self._testEqual('', ('e', {}, [], None)) def testSimpleWithAttrs(self): self._testEqual('', ('e', {'a1': 'v1', 'a2': 'v2'}, [], None)) def testSimpleWithBody(self): self._testEqual('b', ('e', {}, [], 'b')) def testSimpleWithChilds(self): self._testEqual('

b

', ('p', {}, [('c1', {}, [], None), ('c2', {}, [], 'b')], None)) def testFull(self): self._testEqual('

b1

', ('p', {'a1':'v1'}, [('c1', {}, [], None), ('c2', {}, [('sc1', {}, [], None)], None)], 'b1')) benallard-galileo-f0ebc19c748d/tests/testParameters.py0000644000000000000000000000430512767450640021177 0ustar 00000000000000import unittest import logging from galileo.config import ( StrParameter, IntParameter, BoolParameter, SetParameter, LogLevelParameter ) class MyArgParse(object): def __init__(self, tester): self.tester = tester def add_argument(self, *args, **kwargs): self.name = kwargs['dest'] def parse_args(self, args): class Args(object): pass a = Args() setattr(a, self.name, args[1]) return a class testStrParameter(unittest.TestCase): def testArgParse(self): p = StrParameter('varName', 'name', ('--paramNames'), 'default', False, "Some help text") ap = MyArgParse(self) p.toArgParse(ap) args = ap.parse_args(['--paramNames', 'value']) d = {} p.fromArgs(args, d) self.assertTrue('varName' in d) self.assertEqual(d['varName'], 'value') def testFile(self): p = StrParameter('varName', 'name', ('--paramNames'), 'default', False, "Some help text") ap = MyArgParse(self) d = {} c = {'name': 'abcd'} self.assertTrue(p.fromFile(c, d)) self.assertTrue('varName' in d) self.assertEqual(d['varName'], 'abcd') def testFileparamOnly(self): p = StrParameter('varName', 'name', ('--paramNames'), 'default', True, "Some help text") ap = MyArgParse(self) d = {} c = {'name': 'abcd'} self.assertTrue(p.fromFile(c, d)) self.assertFalse('varName' in d) class testBoolParameter(unittest.TestCase): pass class testLogLevelParameter(unittest.TestCase): def testWrongValuefromFile(self): p = LogLevelParameter() d = {} c = {'logging': 'foo'} self.assertFalse(p.fromFile(c, d)) def testCorrectValueUpper(self): p = LogLevelParameter() d = {} c = {'logging': 'DEBUG'} self.assertTrue(p.fromFile(c, d)) self.assertEqual(d['logLevel'], logging.DEBUG) def testCorrectValueLower(self): p = LogLevelParameter() d = {} c = {'logging': 'quiet'} self.assertTrue(p.fromFile(c, d)) self.assertEqual(d['logLevel'], logging.WARNING) benallard-galileo-f0ebc19c748d/tests/testTracker.py0000644000000000000000000000176612767450640020477 0ustar 00000000000000import unittest from galileo.tracker import Tracker class testfromDiscovery(unittest.TestCase): def testOk(self): t = Tracker.fromDiscovery([0xE5, 0x14, 0x53, 0x33, 0xEE, 0xFF, 0x01, 0xBC, 0x02, 0x05, 0x04, 0x03, 0x2C, 0x31, 0xF6, 0xD8, 0x58]) self.assertEqual(t.id, [0xE5, 0x14, 0x53, 0x33, 0xEE, 0xFF]) self.assertEqual(t.addrType, 1) self.assertEqual(t.RSSI, -68) self.assertEqual(len(t.serviceData), 2 + 1) self.assertEqual(t.serviceData, [5,4,3]) self.assertEqual(t.serviceUUID, 22744) def testSurge(self): t = Tracker.fromDiscovery([0xB2, 0x94, 0x82, 0x6E, 0x0C, 0xC8, 0x01, 0xD1, 0x05, 0x10, 0x06, 0xA7, 0x66, 0x03, 0x4A, 0x00, 0xFB]) self.assertEqual(t.id, [178,148,130,110,12,200]) self.assertEqual(t.addrType, 1) self.assertEqual(t.RSSI, -47) self.assertEqual(len(t.serviceData), 5 + 1) self.assertEqual(t.serviceData, [16,6,167,102,3,74]) self.assertEqual(t.serviceUUID, 64256) benallard-galileo-f0ebc19c748d/tests/testUI.py0000644000000000000000000000333212767450640017410 0ustar 00000000000000import unittest from galileo.ui import Form, FormField, MissingConfigError class testFormField(unittest.TestCase): def teststr(self): self.assertEqual("'name': 'value'", str(FormField('name', 'text', 'value'))) self.assertEqual("'name': None", str(FormField('name', 'text'))) def testasXML(self): self.assertEqual(FormField('name').asXMLParam(), ('param', {'name': 'name'}, [], None)) self.assertEqual(FormField('name', value='value').asXMLParam(), ('param', {'name': 'name'}, [], 'value')) class testHTMLForm(unittest.TestCase): def testasXML(self): f = Form() f.addField(FormField('name')) f.addField(FormField('name2')) tpl = f.asXML() self.assertEqual(len(tpl), 2) self.assertIn(('param', {'name': 'name'}, [], None), tpl) self.assertIn(('param', {'name': 'name2'}, [], None), tpl) def testasXML2Submit(self): f = Form() f.addField(FormField('name', 'submit')) f.addField(FormField('name2', 'submit')) f.takeValuesFromAnswer({'name2': None}) self.assertEqual(f.asXML(), [('param', {'name': 'name2'}, [], None)]) class testMissingConfigClass(unittest.TestCase): def testStr(self): f = Form() f.addField(FormField('name')) f.addField(FormField('name2')) f2 = Form() f2.addField(FormField('a')) f2.addField(FormField('b')) f2.addField(FormField('c')) mce = MissingConfigError('test', [f, f2]) s = str(mce) self.assertTrue(str(f.asDict()) in s) self.assertTrue(str(f2.asDict()) in s) self.assertTrue('`--debug`' in s) self.assertTrue("'test'" in s) self.assertTrue("'hardcoded-ui'" in s) benallard-galileo-f0ebc19c748d/tests/testUtils.py0000644000000000000000000000665012767450640020201 0ustar 00000000000000import unittest from galileo.utils import a2x, a2s, a2lsbi, a2msbi, i2lsba, s2a, x2a class testa2x(unittest.TestCase): def testSimple(self): self.assertEqual(a2x(range(10)), '00 01 02 03 04 05 06 07 08 09') def testNotShorten(self): self.assertEqual(a2x([0] * 5), '00 00 00 00 00') def testDelim(self): self.assertEqual(a2x(range(190, 196), '|'), 'BE|BF|C0|C1|C2|C3') class testx2a(unittest.TestCase): def testSimple(self): self.assertEqual(x2a('2'), [2]) self.assertEqual(x2a('02'), [2]) self.assertEqual(x2a('2 3'), [2, 3]) class testa2s(unittest.TestCase): def testSimple(self): self.assertEqual(a2s(range(ord('a'), ord('d') + 1)), 'abcd') def testWithNUL(self): self.assertEqual( a2s(list(range(ord('a'), ord('d')+1)) + [0]*3 + list(range(ord('e'), ord('i')+1))), 'abcd') def testWithNULNotPrint(self): self.assertEqual( a2s(list(range(ord('a'), ord('d')+1)) + [0]*3 + list(range(ord('e'), ord('i')+1)), False), 'abcd\0\0\0efghi') class testa2lsbi(unittest.TestCase): def test0(self): self.assertEqual(a2lsbi([0]), 0) self.assertEqual(a2lsbi([0]*3), 0) self.assertEqual(a2lsbi([0]*10), 0) def test1byte(self): self.assertEqual(a2lsbi([8]), 8) self.assertEqual(a2lsbi([0xff]), 0xff) self.assertEqual(a2lsbi([0x80]), 0x80) def test2bytes(self): self.assertEqual(a2lsbi([1, 0]), 1) self.assertEqual(a2lsbi([0xff, 0]), 0xff) self.assertEqual(a2lsbi([0x80, 0]), 0x80) self.assertEqual(a2lsbi([0, 1]), 0x100) self.assertEqual(a2lsbi([0, 0xff]), 0xff00) self.assertEqual(a2lsbi([0, 0x80]), 0x8000) class testa2msbi(unittest.TestCase): def test0(self): self.assertEqual(a2msbi([0]), 0) self.assertEqual(a2msbi([0]*3), 0) self.assertEqual(a2msbi([0]*10), 0) def test1byte(self): self.assertEqual(a2msbi([8]), 8) self.assertEqual(a2msbi([0xff]), 0xff) self.assertEqual(a2msbi([0x80]), 0x80) def test2bytes(self): self.assertEqual(a2msbi([1, 0]), 0x100) self.assertEqual(a2msbi([0xff, 0]), 0xff00) self.assertEqual(a2msbi([0x80, 0]), 0x8000) self.assertEqual(a2msbi([0, 1]), 0x1) self.assertEqual(a2msbi([0, 0xff]), 0xff) self.assertEqual(a2msbi([0, 0x80]), 0x80) class testi2lsba(unittest.TestCase): def test0(self): self.assertEqual(i2lsba(0, 1), [0]) self.assertEqual(i2lsba(0, 3), [0]*3) self.assertEqual(i2lsba(0, 5), [0]*5) def test1byte(self): self.assertEqual(i2lsba(1, 1), [1]) self.assertEqual(i2lsba(0xff, 1), [0xff]) self.assertEqual(i2lsba(0x80, 1), [0x80]) def test2bytes(self): self.assertEqual(i2lsba(1, 2), [1, 0]) self.assertEqual(i2lsba(0xff, 2), [0xff, 0]) self.assertEqual(i2lsba(0x80, 2), [0x80, 0]) self.assertEqual(i2lsba(0x100, 2), [0, 1]) self.assertEqual(i2lsba(0xff00, 2), [0, 0xff]) self.assertEqual(i2lsba(0x8000, 2), [0, 0x80]) class tests2a(unittest.TestCase): def testSimple(self): self.assertEqual(s2a('abcd'), list(range(ord('a'), ord('d')+1))) def testWithNUL(self): self.assertEqual(s2a('abcd\0\0\0efghi'), list(range(ord('a'), ord('d')+1)) + [0] * 3 + list(range(ord('e'), ord('i') + 1))) benallard-galileo-f0ebc19c748d/tests/testYAMLParser.py0000644000000000000000000000564412767450640021022 0ustar 00000000000000import unittest from galileo import parser class testUtilities(unittest.TestCase): def testStripCommentEmpty(self): self.assertEqual(parser._stripcomment(""), "") def testStripCommentOnlyComment(self): self.assertEqual(parser._stripcomment("# abcd"), "") def testStripCommentSmallLine(self): self.assertEqual(parser._stripcomment("ab # cd"), "ab") def testStripCommentDoubleComment(self): self.assertEqual(parser._stripcomment("ab # cd # ef"), "ab") def testdedent1(self): self.assertEqual(parser._dedent("""\ a: - a - b """.split('\n'), 1), [' - a', ' - b']) def testdedent2(self): self.assertEqual(parser._dedent("""\ - a: b c: 5 """.split('\n'), 1), [' a:', ' b', ' c:', ' 5']) class testload(unittest.TestCase): def testEmpty(self): self.assertEqual(parser.loads(""), None) def testSimpleComment(self): self.assertEqual(parser.loads("""\ # This is a comment """), None) def testOneKey(self): self.assertEqual(parser.loads("""\ test: """), {"test":None}) def testOneKeyWithComment(self): self.assertEqual(parser.loads("""\ test: # This is the test Key """), {"test": None}) def testMultiLines(self): self.assertEqual(parser.loads("\n"*5 + "test: # This is the test Key" + "\n" * 8), {"test": None}) def testMultipleKeys(self): self.assertEqual(parser.loads(""" test: test_2: test-3: """), {"test": None, 'test_2': None, 'test-3': None}) def testOnlyOneValue(self): self.assertEqual(parser.loads('5'), 5) self.assertEqual(parser.loads('a'), 'a') self.assertEqual(parser.loads('true'), True) def testOneArray(self): self.assertEqual(parser.loads('- a\n- b'), ['a', 'b']) def testIntegerValue(self): self.assertEqual(parser.loads("t: 5"), {'t': 5}) def testSimpleStringValue(self): self.assertEqual(parser.loads('t: abcd'), {'t': 'abcd'}) def testStringValue(self): self.assertEqual(parser.loads("t: '5'"), {'t': '5'}) def testOtherStringValue(self): self.assertEqual(parser.loads('t: "5"'), {'t': '5'}) def testBoolValue(self): self.assertEqual(parser.loads("t: false"), {'t': False}) def testInlineArrayValue(self): self.assertEqual(parser.loads("t: [4, 6]"), {'t': [4, 6]}) def testArrayValue(self): self.assertEqual(parser.loads(""" test: - a - 5 """), {'test': ['a', 5]}) def testDoubleDict(self): self.assertEqual(parser.loads("""\ a: b: c """), {'a': {'b': 'c'}}) def testDoubleDict2(self): self.assertEqual(parser.loads("""\ a: b: c """), {'a': {'b': 'c'}}) def testMultiArray(self): self.assertEqual(parser.loads("""\ - - a: b c: 5 - a: 8 """), [[{'a':'b', 'c': 5}, {'a': 8}]]) def testArrayOfDict(self): self.assertEqual(parser.loads("""\ - a: b """), [{'a':'b'}]) benallard-galileo-f0ebc19c748d/trace.txt0000644000000000000000000001050512767450640016316 0ustar 00000000000000--> 02 - 1 <-- CancelDiscovery <-- TerminateLink <-- ... --> 01 - 2 <-- 08 ( 01 01 6F 7B AD 29 6A BC 74 09 00 20 00 00 FF E7 03 00 01) - 21 --> 04 ( BA 56 89 A6 FA BF A2 BD 01 46 7D 6E 00 00 AB AD 00 FB 01 FB 02 FB A0 0F) - 26 <-- StartDiscovery <-- 03 ( E5 14 53 33 EE FF 01 BC 02 05 04 03 2C 31 F6 D8 58 ) - 19 --> 05 - 2 <-- 02 ( 01 ) - 3 <-- CancelDiscovery --> 06 ( E5 14 53 33 EE FF 01 D8 58 ) - 11 <-- EstablishLink <-- 04 ( 00 ) - 3 <-- GAP_LINK_ESTABLISHED_EVENT <-- 07 - 2 --> 08 ( 01 ) - 3 <== [ C0 0B ] - 2 ==> [ C0 0A 0A 00 06 00 06 00 00 00 C8 00 ] - 12 <-- 06 ( 06 00 00 00 C8 00 ) - 8 <== [ C0 14 0C 01 00 00 E5 14 53 33 EE FF ] - 12 Megadump ==> [ C0 10 0D ] - 3 <== [ C0 41 0D ] - 3 <== [ 26 02 00 00 00 00 00 00 00 00 DF 29 F5 4B 29 05 06 2E 06 2E ] - 20 <== [ 00 00 91 7E 75 03 00 00 00 00 14 14 FD 0F 14 64 00 00 00 00 ] - 20 <== [ EB 02 A8 03 81 2A 52 09 1A 1F 00 00 00 00 00 00 00 FB CB 00 ] - 20 <== [ 42 45 4E 20 20 20 20 20 20 20 43 48 45 45 52 53 20 20 20 20 ] - 20 <== [ 48 45 4C 4C 4F 20 20 20 20 20 47 4F 20 20 20 20 20 20 20 20 ] - 20 <== [ 1F 00 00 00 00 20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ] - 20 <== [ 00 00 01 00 C0 DB DC DD D2 25 92 52 08 01 05 92 52 00 D2 25 ] - 20 <== [ 00 00 00 D2 25 92 52 0B 00 05 00 00 00 00 00 00 00 00 D2 25 ] - 20 <== [ 92 52 0B 01 05 00 00 00 03 00 00 00 00 D2 25 92 52 0B 02 05 ] - 20 <== [ 00 00 00 00 00 00 00 00 D2 25 92 52 0B 03 05 00 00 00 00 00 ] - 20 <== [ 00 00 00 C0 C0 DB DC DD 52 92 26 04 81 1C 1B 01 81 0A 00 02 ] - 20 <== [ 81 18 00 02 81 0A 00 03 81 0A 00 03 81 1E 15 07 81 0A 00 03 ] - 20 <== [ 81 0A 00 03 81 0A 00 02 81 0A 00 02 81 0A 00 02 52 92 28 D4 ] - 20 <== [ 81 1A 00 05 81 0A 00 05 81 0A 00 02 81 0A 00 02 81 0A 00 02 ] - 20 <== [ 81 0A 00 06 81 1C 18 05 52 92 2A B4 81 20 1F 06 81 1A 0D 06 ] - 20 <== [ 81 0A 00 03 81 0A 00 07 81 0A 00 03 C0 C0 DB DC DD C0 C0 DB ] - 20 <== [ DC DD C1 2B 92 52 D8 34 84 14 00 00 2C DD 3B 00 1E 00 C0 00 ] - 20 <== [ 00 00 00 C0 DB DC DD C0 40 1F 00 00 00 00 00 00 43 01 00 ] - 19 <== [ C0 42 0D 0C 37 67 01 00 00 ] - 9 Done 6de4df71-17f9-43ea-9854-67f842021e050.1syncJgIAAAAAAAAAAN8p9UspBQYuBi4AAJF+dQMAAAAAFBT9DxRkAAAAAOsCqAOBKlIJGh8AAAAAAAAA+8sAQkVOICAgICAgIENIRUVSUyAgICBIRUxMTyAgICAgR08gICAgICAgIB8AAAAAIAAAAAAAAAAAAAAAAAAAAAABAMDb3N3SJZJSCAEFklIA0iUAAADSJZJSCwAFAAAAAAAAAADSJZJSCwEFAAAAAwAAAADSJZJSCwIFAAAAAAAAAADSJZJSCwMFAAAAAAAAAADAwNvc3VKSJgSBHBsBgQoAAoEYAAKBCgADgQoAA4EeFQeBCgADgQoAA4EKAAKBCgACgQoAAlKSKNSBGgAFgQoABYEKAAKBCgACgQoAAoEKAAaBHBgFUpIqtIEgHwaBGg0GgQoAA4EKAAeBCgADwMDb3N3AwNvc3cErklLYNIQUAAAs3TsAHgDAAAAAAMDb3N3AQB8AAAAAAABDAQA= JgIAAAAAAQAAAAAAAADrAqgDgSpSCRofAAAAAAAAAPvLAEJFTiAgICAgICBIT1dEWSAgICAgU1RFUEdFRUsgIFJPQ0sgT04gICAfAAAAACAAAAAAAAAAAAAAAAAAAAAAAQDDK5JSAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAUAAAAFwyuSUgLSJZJSAaQrklIEwSuSUgfDK5JSqgMAAAAAAACPAAA= ==> [ C0 24 04 A4 00 00 00 00 00 ] - 9 <== [ C0 12 04 00 00 ] - 5 ==> [ 26 02 00 00 00 00 01 00 00 00 00 00 00 00 EB 02 A8 03 81 2A ] - 20 <== [ C0 13 14 00 00 ] - 5 ==> [ 52 09 1A 1F 00 00 00 00 00 00 00 FB CB 00 42 45 4E 20 20 20 ] - 20 <== [ C0 13 24 00 00 ] - 5 ==> [ 20 20 20 20 48 4F 57 44 59 20 20 20 20 20 53 54 45 50 47 45 ] - 20 <== [ C0 13 34 00 00 ] - 5 ==> [ 45 4B 20 20 52 4F 43 4B 20 4F 4E 20 20 20 1F 00 00 00 00 20 ] - 20 <== [ C0 13 44 00 00 ] - 5 ==> [ 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 C3 2B ] - 20 <== [ C0 13 54 00 00 ] - 5 ==> [ 92 52 00 00 00 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 ] - 20 <== [ C0 13 64 00 00 ] - 5 ==> [ 00 00 00 00 05 00 00 00 05 C3 2B 92 52 02 D2 25 92 52 01 A4 ] - 20 <== [ C0 13 74 00 00 ] - 5 ==> [ 2B 92 52 04 C1 2B 92 52 07 C3 2B 92 52 AA 03 00 00 00 00 00 ] - 20 <== [ C0 13 84 00 00 ] - 5 ==> [ 00 8F 00 00 ] - 4 <== [ C0 13 94 00 00 ] - 5 ==> [ C0 02 ] - 2 <== [ C0 02 ] - 2 ==> [ C0 01 ] - 2 <== [ C0 01 ] - 2 --> 08 ( 00 ) - 3 <== [ C0 0B ] - 2 --> 07 - 2 <-- TerminateLink <-- 05 ( 16 ) - 3 <-- GAP_LINK_TERMINATED_EVENT <-- 22