[med-svn] [galileo] 02/03: Imported Upstream version 0.5.0~git160510+dfsg
Dylan Aïssi
bob.dybian-guest at moszumanska.debian.org
Fri May 13 22:15:32 UTC 2016
This is an automated email from the git hooks/post-receive script.
bob.dybian-guest pushed a commit to branch master
in repository galileo.
commit 0b7cf53f6312d2f57e6d19657e4b2c554b71b1f4
Author: Dylan Aïssi <bob.dybian at gmail.com>
Date: Sat May 14 00:10:37 2016 +0200
Imported Upstream version 0.5.0~git160510+dfsg
---
99-fitbit.rules | 2 +
CHANGES | 190 +++++++++++++++++++++
COPYING | 165 ++++++++++++++++++
README.txt | 162 ++++++++++++++++++
analysedir.py | 45 +++++
analysedump.py | 239 ++++++++++++++++++++++++++
contrib/README.txt | 27 +++
contrib/galileo.service | 17 ++
contrib/galileo.upstart | 9 +
diff.py | 106 ++++++++++++
doc/galileo.1 | 205 ++++++++++++++++++++++
doc/galileorc.5 | 162 ++++++++++++++++++
galileo/__init__.py | 5 +
galileo/config.py | 359 +++++++++++++++++++++++++++++++++++++++
galileo/conversation.py | 203 ++++++++++++++++++++++
galileo/dongle.py | 322 +++++++++++++++++++++++++++++++++++
galileo/dump.py | 130 ++++++++++++++
galileo/interactive.py | 223 ++++++++++++++++++++++++
galileo/main.py | 301 ++++++++++++++++++++++++++++++++
galileo/net.py | 257 ++++++++++++++++++++++++++++
galileo/parser.py | 120 +++++++++++++
galileo/tracker.py | 348 +++++++++++++++++++++++++++++++++++++
galileo/ui.py | 237 ++++++++++++++++++++++++++
galileo/utils.py | 68 ++++++++
galileorc.sample | 35 ++++
run | 5 +
setup.py | 84 +++++++++
tests/__init__.py | 0
tests/testCRC.py | 19 +++
tests/testConfig.py | 67 ++++++++
tests/testDataRing.py | 59 +++++++
tests/testDongle.py | 92 ++++++++++
tests/testDump.py | 81 +++++++++
tests/testFitbitClient.py | 415 +++++++++++++++++++++++++++++++++++++++++++++
tests/testFormExtractor.py | 31 ++++
tests/testGalileoClient.py | 247 +++++++++++++++++++++++++++
tests/testNetUtils.py | 79 +++++++++
tests/testParameters.py | 75 ++++++++
tests/testTracker.py | 24 +++
tests/testUI.py | 46 +++++
tests/testUtils.py | 111 ++++++++++++
tests/testYAMLParser.py | 116 +++++++++++++
trace.txt | 81 +++++++++
43 files changed, 5569 insertions(+)
diff --git a/99-fitbit.rules b/99-fitbit.rules
new file mode 100644
index 0000000..626a5d8
--- /dev/null
+++ b/99-fitbit.rules
@@ -0,0 +1,2 @@
+# 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"
diff --git a/CHANGES b/CHANGES
new file mode 100644
index 0000000..6869c6e
--- /dev/null
+++ b/CHANGES
@@ -0,0 +1,190 @@
+galileo 0.5 (????-??-??)
+------------------------
+
+This is the next feature release of galileo, a free utility to securely
+synchronize fitbit bluetooth trackers with the fitbit web-service.
+
+This release ...
+
+Contributor to this release: Dean Giberson, Richard Weait, Chris Wayne,
+David Vasak, Mike Frysinger and Nenad Jankovic.
+
+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)
+
+
+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/<TRACKERID>/ directory.
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..65c5ca8
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ 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.
diff --git a/README.txt b/README.txt
new file mode 100644
index 0000000..c887e09
--- /dev/null
+++ b/README.txt
@@ -0,0 +1,162 @@
+Galileo
+=======
+
+:author: Benoît Allard <benoit.allard at gmx.de>
+:version: 0.5dev
+:license: LGPLv3+
+:bug tracker: https://bitbucket.org/benallard/galileo/issues
+:mailing list: galileo at freelists.org (subscribe_, archive_)
+:build status: |droneio_badge|_
+
+.. _subscribe: mailto:galileo-request at 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 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 <http://git.overlays.gentoo.org/gitweb/?p=user/squeezebox.git>`_
+ overlay. See https://wiki.gentoo.org/wiki/Layman for details of how
+ to use Gentoo overlays.
+**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
+
+.. _AUR: https://aur.archlinux.org/packages/galileo/
+.. _yaourt: https://wiki.archlinux.org/index.php/yaourt
+
+.. _`COPR repo`: https://copr.fedoraproject.org/coprs/stbenjam/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 <https://github.com/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.
diff --git a/analysedir.py b/analysedir.py
new file mode 100755
index 0000000..3634d6a
--- /dev/null
+++ b/analysedir.py
@@ -0,0 +1,45 @@
+#!/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])
diff --git a/analysedump.py b/analysedump.py
new file mode 100755
index 0000000..236ca9c
--- /dev/null
+++ b/analysedump.py
@@ -0,0 +1,239 @@
+#!/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)
diff --git a/contrib/README.txt b/contrib/README.txt
new file mode 100644
index 0000000..e1f6739
--- /dev/null
+++ b/contrib/README.txt
@@ -0,0 +1,27 @@
+Miscellaneous 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.
diff --git a/contrib/galileo.service b/contrib/galileo.service
new file mode 100644
index 0000000..f81a17c
--- /dev/null
+++ b/contrib/galileo.service
@@ -0,0 +1,17 @@
+# 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
diff --git a/contrib/galileo.upstart b/contrib/galileo.upstart
new file mode 100644
index 0000000..1c23bc8
--- /dev/null
+++ b/contrib/galileo.upstart
@@ -0,0 +1,9 @@
+description "Galileo to sync fitbit devices"
+
+start on started dbus
+stop on desktop-end
+
+respawn
+respawn limit unlimited
+
+exec /usr/bin/galileo daemon
diff --git a/diff.py b/diff.py
new file mode 100755
index 0000000..1929fb2
--- /dev/null
+++ b/diff.py
@@ -0,0 +1,106 @@
+#!/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])
diff --git a/doc/galileo.1 b/doc/galileo.1
new file mode 100644
index 0000000..d1afc47
--- /dev/null
+++ b/doc/galileo.1
@@ -0,0 +1,205 @@
+.\" 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.5dev "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 [ "\-\-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
+.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>
diff --git a/doc/galileorc.5 b/doc/galileorc.5
new file mode 100644
index 0000000..876772a
--- /dev/null
+++ b/doc/galileorc.5
@@ -0,0 +1,162 @@
+.\" 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.5dev "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 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>.
diff --git a/galileo/__init__.py b/galileo/__init__.py
new file mode 100644
index 0000000..883bb2c
--- /dev/null
+++ b/galileo/__init__.py
@@ -0,0 +1,5 @@
+"""\
+galileo.py Utility to synchronize a fitbit tracker with the fitbit server.
+"""
+
+__version__ = '0.5dev'
diff --git a/galileo/config.py b/galileo/config.py
new file mode 100644
index 0000000..e568363
--- /dev/null
+++ b/galileo/config.py
@@ -0,0 +1,359 @@
+import 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"),
+ 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)
diff --git a/galileo/conversation.py b/galileo/conversation.py
new file mode 100644
index 0000000..bbf9349
--- /dev/null
+++ b/galileo/conversation.py
@@ -0,0 +1,203 @@
+"""\
+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)
diff --git a/galileo/dongle.py b/galileo/dongle.py
new file mode 100644
index 0000000..660fde8
--- /dev/null
+++ b/galileo/dongle.py
@@ -0,0 +1,322 @@
+from __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
+ self.establishLinkEx = (major, minor) >= (7, 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
diff --git a/galileo/dump.py b/galileo/dump.py
new file mode 100644
index 0000000..51e87e8
--- /dev/null
+++ b/galileo/dump.py
@@ -0,0 +1,130 @@
+import 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__
diff --git a/galileo/interactive.py b/galileo/interactive.py
new file mode 100644
index 0000000..531dc58
--- /dev/null
+++ b/galileo/interactive.py
@@ -0,0 +1,223 @@
+"""\
+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
+
+#---------------------------
+# 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
+
+
+ at command('x', "Quit")
+def quit():
+ print('Bye !')
+ global exit
+ exit = True
+
+
+ at 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 = raw_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
+
+ at 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
+
+ at command('->', "Send on the control channel")
+ at needfitbit
+def send_ctrl(INS, *payload):
+ if payload:
+ payload = x2a(' '.join(payload))
+ else:
+ payload = []
+ m = CM(int(INS, 16), payload)
+ dongle.ctrl_write(m)
+
+ at command('<-', "Receive once on the control channel")
+ at 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()
+
+ at command('=>', "Send on the control channel")
+ at needfitbit
+def send_data(*payload):
+ m = DM(x2a(' '.join(payload)))
+ dongle.data_write(m)
+
+ at command('<=', "Receive once on the control channel")
+ at 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()
+
+ at command('d', "Discovery")
+ at 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
+
+ at command('l', "establishLink")
+ at needtrackers
+def establishLink(idx='0'):
+ global tracker
+ tracker = trackers[int(idx)]
+ if fitbit.establishLink(tracker):
+ print('Ok')
+ else:
+ tracker = None
+
+ at command('L', "ceaseLink")
+ at 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
+
+ at command('tx', "toggle Tx Pipe")
+ at needfitbit
+def toggleTxPipe(on):
+ if fitbit.toggleTxPipe(bool(int(on))):
+ print('Ok')
+
+ at command('al', "initialise airLink")
+ at needtracker
+def initialiseAirLink():
+ if fitbit.initializeAirlink(tracker):
+ print('Ok')
+
+ at command('AL', "terminate airLink")
+ at needfitbit
+def terminateairLink():
+ if fitbit.terminateAirlink():
+ print('Ok')
+
+ at command('D', 'getDump')
+ at needfitbit
+def getDump(type="13"):
+ fitbit.getDump(int(type))
+
+ at command('R', 'uploadResponse')
+ at needfitbit
+def uploadResponse(*response):
+ response = x2a(' '.join(response))
+ fitbit.uploadResponse(response)
diff --git a/galileo/main.py b/galileo/main.py
new file mode 100644
index 0000000..eb1e7e3
--- /dev/null
+++ b/galileo/main.py
@@ -0,0 +1,301 @@
+from __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', 'client.fitbit.com',
+ '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')
+ # TODO: Make address into a config option.
+ 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)
diff --git a/galileo/net.py b/galileo/net.py
new file mode 100644
index 0000000..378ca3f
--- /dev/null
+++ b/galileo/net.py
@@ -0,0 +1,257 @@
+
+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))
diff --git a/galileo/parser.py b/galileo/parser.py
new file mode 100644
index 0000000..61c1b15
--- /dev/null
+++ b/galileo/parser.py
@@ -0,0 +1,120 @@
+"""\
+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))
diff --git a/galileo/tracker.py b/galileo/tracker.py
new file mode 100644
index 0000000..c54aa11
--- /dev/null
+++ b/galileo/tracker.py
@@ -0,0 +1,348 @@
+from 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(), 'CancelDiscovery'):
+ return False
+ 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
diff --git a/galileo/ui.py b/galileo/ui.py
new file mode 100644
index 0000000..be26673
--- /dev/null
+++ b/galileo/ui.py
@@ -0,0 +1,237 @@
+"""\
+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
+
+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('<![CDATA[') and html.endswith(']]>'):
+ html = html[len('<![CDATA['):-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 <Enter>.
+ 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 = raw_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 = raw_input()
+ return [('param', {'name': 'secret'}, [], secret)]
diff --git a/galileo/utils.py b/galileo/utils.py
new file mode 100644
index 0000000..b6ba618
--- /dev/null
+++ b/galileo/utils.py
@@ -0,0 +1,68 @@
+"""\
+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]
diff --git a/galileorc.sample b/galileorc.sample
new file mode 100644
index 0000000..6c55b77
--- /dev/null
+++ b/galileorc.sample
@@ -0,0 +1,35 @@
+
+# -*- 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
diff --git a/run b/run
new file mode 100755
index 0000000..90b30b8
--- /dev/null
+++ b/run
@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+
+from galileo.main import main
+
+main()
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..65aecdb
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import re
+import sys
+
+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"""
+ pass
+
+ def finalize_options(self):
+ """finalize options"""
+ pass
+
+ 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') 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 at 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,
+ },
+)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/testCRC.py b/tests/testCRC.py
new file mode 100644
index 0000000..e2bf26e
--- /dev/null
+++ b/tests/testCRC.py
@@ -0,0 +1,19 @@
+import 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)
diff --git a/tests/testConfig.py b/tests/testConfig.py
new file mode 100644
index 0000000..d9a22f6
--- /dev/null
+++ b/tests/testConfig.py
@@ -0,0 +1,67 @@
+import 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))
diff --git a/tests/testDataRing.py b/tests/testDataRing.py
new file mode 100644
index 0000000..b270e2b
--- /dev/null
+++ b/tests/testDataRing.py
@@ -0,0 +1,59 @@
+import 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())
diff --git a/tests/testDongle.py b/tests/testDongle.py
new file mode 100644
index 0000000..3e9375f
--- /dev/null
+++ b/tests/testDongle.py
@@ -0,0 +1,92 @@
+import 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')))
diff --git a/tests/testDump.py b/tests/testDump.py
new file mode 100644
index 0000000..3ff9e21
--- /dev/null
+++ b/tests/testDump.py
@@ -0,0 +1,81 @@
+import 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())
diff --git a/tests/testFitbitClient.py b/tests/testFitbitClient.py
new file mode 100644
index 0000000..ca0b10a
--- /dev/null
+++ b/tests/testFitbitClient.py
@@ -0,0 +1,415 @@
+import 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, 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, 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, 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, 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))
diff --git a/tests/testFormExtractor.py b/tests/testFormExtractor.py
new file mode 100644
index 0000000..1546353
--- /dev/null
+++ b/tests/testFormExtractor.py
@@ -0,0 +1,31 @@
+import unittest
+
+from galileo.ui import FormExtractor, FormField
+
+class testFormExtractor(unittest.TestCase):
+
+ def testEasy(self):
+ fe = FormExtractor()
+ fe.feed('<html><body><form><input type="text" name="username"><input type="text" name="password"></form></body></html>')
+ 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('<html><body><form><input name="username" type="hidden" value="User"><input type="text" name="password"></form></body></html>')
+ self.assertEqual(len(fe.forms), 1)
+ self.assertEqual(fe.forms[0].asDict(), {'username': 'User', 'password': None})
+
+ def testSelect(self):
+ fe = FormExtractor()
+ fe.feed('<html><body><form><select name="choice" ><option value="A" /><option value="B" selected></select></form></body></html>')
+ 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'''<!DOCTYPE html><input class="button" type="submit" name="again" value="Next" />''')
+ self.assertEqual(len(fe.forms), 1)
+ self.assertEqual(fe.forms[0].asDict(), {'again': 'Next'})
diff --git a/tests/testGalileoClient.py b/tests/testGalileoClient.py
new file mode 100644
index 0000000..2c3012a
--- /dev/null
+++ b/tests/testGalileoClient.py
@@ -0,0 +1,247 @@
+import 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='<server-version>\n\n</server-version>'):
+ self.text = """<?xml version="1.0" encoding="utf-8" standalone="yes"?><galileo-server version="2.0">%s%s</galileo-server>""" % (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'), """\
+<?xml version='1.0' encoding='utf-8'?>
+<galileo-client version="2.0"><client-info><client-id>%(id)s</client-id><client-version>%(version)s</client-version><client-mode>status</client-mode></client-info></galileo-client>""" % {
+ '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'), """\
+<?xml version='1.0' encoding='utf-8'?>
+<galileo-client version="2.0"><client-info><client-id>%(id)s</client-id><client-version>%(version)s</client-version><client-mode>status</client-mode></client-info></galileo-client>""" % {
+ 'id': GalileoClient.ID,
+ 'version': __version__})
+ self.assertEqual(headers['Content-Type'], 'text/xml')
+ return requestResponse('<error>Something is Wrong</error>')
+
+ 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'), """\
+<?xml version='1.0' encoding='utf-8'?>
+<galileo-client version="2.0"><client-info><client-id>%(id)s</client-id><client-version>%(version)s</client-version><client-mode>status</client-mode></client-info></galileo-client>""" % {
+ 'id': GalileoClient.ID,
+ 'version': __version__})
+ self.assertEqual(headers['Content-Type'], 'text/xml')
+ return requestResponse("""
+ <back-off>
+ <min>1800000</min>
+ <max>3600000</max>
+ </back-off>
+ <ui-request action="login">
+ <client-display height="450" width="650" minDisplayTimeMs="20000" containsForm="false">
+ Server is in maintenance mode. We'll be back soon!
+ </client-display>
+ </ui-request>""", '')
+
+ 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'), """\
+<?xml version='1.0' encoding='utf-8'?>
+<galileo-client version="2.0"><client-info><client-id>%(id)s</client-id><client-version>%(version)s</client-version><client-mode>status</client-mode></client-info></galileo-client>""" % {
+ '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'), """\
+<?xml version='1.0' encoding='utf-8'?>
+<galileo-client version="2.0"><client-info><client-id>%(id)s</client-id><client-version>%(version)s</client-version><client-mode>sync</client-mode><dongle-version major="%(M)d" minor="%(m)d" /></client-info><tracker tracker-id="%(t_id)s"><data>%(b64dump)s</data></tracker></galileo-client>""" % {
+ '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('<tracker tracker-id="abcd" type="megadumpresponse"><data>ZWZnaA==</data></tracker>')
+
+ 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'), """\
+<?xml version='1.0' encoding='utf-8'?>
+<galileo-client version="2.0"><client-info><client-id>%(id)s</client-id><client-version>%(version)s</client-version><client-mode>sync</client-mode><dongle-version major="%(M)d" minor="%(m)d" /></client-info><tracker tracker-id="%(t_id)s"><data>%(b64dump)s</data></tracker></galileo-client>""" % {
+ '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'), """\
+<?xml version='1.0' encoding='utf-8'?>
+<galileo-client version="2.0"><client-info><client-id>%(id)s</client-id><client-version>%(version)s</client-version><client-mode>sync</client-mode><dongle-version major="%(M)d" minor="%(m)d" /></client-info><tracker tracker-id="%(t_id)s"><data>%(b64dump)s</data></tracker></galileo-client>""" % {
+ '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('<tracker tracker-id="abcd" type="megadumpresponse"></tracker>')
+
+ 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'), """\
+<?xml version='1.0' encoding='utf-8'?>
+<galileo-client version="2.0"><client-info><client-id>%(id)s</client-id><client-version>%(version)s</client-version><client-mode>sync</client-mode><dongle-version major="%(M)d" minor="%(m)d" /></client-info><tracker tracker-id="%(t_id)s"><data>%(b64dump)s</data></tracker></galileo-client>""" % {
+ '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('<tracker tracker-id="abcd" type="megadumpresponse"><not_data /></tracker>')
+
+ 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
diff --git a/tests/testNetUtils.py b/tests/testNetUtils.py
new file mode 100644
index 0000000..777378f
--- /dev/null
+++ b/tests/testNetUtils.py
@@ -0,0 +1,79 @@
+import 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'), '<elem />')
+ def testSimpleWithAttrs(self):
+ self._testEqual(toXML('elem', {'attr1': 'val',
+ 'attr2': 'val'}),
+ '<elem attr1="val" attr2="val" />')
+ def testSimpleWithBody(self):
+ self._testEqual(toXML('elem', body="body"), '<elem>body</elem>')
+ def testSimpleWithChilds(self):
+ self._testEqual(toXML('parent', childs=[('child1',), ('child2',)]),
+ '<parent><child1 /><child2 /></parent>')
+ def testFull(self):
+ self._testEqual(toXML('parent', {'a':'c'}, [('c',),
+ ('c', {}, [], 'b')], 'b'),
+ '<parent a="c">b<c /><c>b</c></parent>')
+
+ def testOnetuplesToXML(self):
+ xmls = list(tuplesToXML(('p',{'a':'a'}, [], 'b')))
+ self.assertEqual(len(xmls), 1)
+ self._testEqual(xmls[0], '<p a="a">b</p>')
+ def testOnetuplesToXML2(self):
+ xmls = list(tuplesToXML([('p',{'a':'a'}, [], 'b')]))
+ self.assertEqual(len(xmls), 1)
+ self._testEqual(xmls[0], '<p a="a">b</p>')
+
+ def testMultipletuplesToXML(self):
+ xmls = list(tuplesToXML([('p',{'a':'a'}, [], 'b'),
+ ('p'),
+ ('p', {}, [], 'b')]))
+ self.assertEqual(len(xmls), 3)
+ self._testEqual(xmls[0], '<p a="a">b</p>')
+ self._testEqual(xmls[1], '<p />')
+ self._testEqual(xmls[2], '<p>b</p>')
+
+class testtoTuple(unittest.TestCase):
+ def _testEqual(self, xmlStr, tpls):
+ tpl = XMLToTuple(ET.fromstring(xmlStr))
+ self.assertEqual(tpl, tpls)
+
+ def testSimple(self):
+ self._testEqual('<e />', ('e', {}, [], None))
+
+ def testSimpleWithAttrs(self):
+ self._testEqual('<e a1="v1" a2="v2"/>', ('e', {'a1': 'v1', 'a2': 'v2'},
+ [], None))
+
+ def testSimpleWithBody(self):
+ self._testEqual('<e>b</e>', ('e', {}, [], 'b'))
+
+ def testSimpleWithChilds(self):
+ self._testEqual('<p><c1 /><c2>b</c2></p>', ('p',
+ {},
+ [('c1', {}, [], None),
+ ('c2', {}, [], 'b')],
+ None))
+
+ def testFull(self):
+ self._testEqual('<p a1="v1">b1<c1 /><c2><sc1 /></c2></p>',
+ ('p',
+ {'a1':'v1'},
+ [('c1', {}, [], None),
+ ('c2', {}, [('sc1', {}, [], None)], None)],
+ 'b1'))
diff --git a/tests/testParameters.py b/tests/testParameters.py
new file mode 100644
index 0000000..f9c3f63
--- /dev/null
+++ b/tests/testParameters.py
@@ -0,0 +1,75 @@
+import 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)
diff --git a/tests/testTracker.py b/tests/testTracker.py
new file mode 100644
index 0000000..2e49bc6
--- /dev/null
+++ b/tests/testTracker.py
@@ -0,0 +1,24 @@
+import 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)
diff --git a/tests/testUI.py b/tests/testUI.py
new file mode 100644
index 0000000..03ca939
--- /dev/null
+++ b/tests/testUI.py
@@ -0,0 +1,46 @@
+import 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)
diff --git a/tests/testUtils.py b/tests/testUtils.py
new file mode 100644
index 0000000..1e06e99
--- /dev/null
+++ b/tests/testUtils.py
@@ -0,0 +1,111 @@
+import 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)))
diff --git a/tests/testYAMLParser.py b/tests/testYAMLParser.py
new file mode 100644
index 0000000..28d3a1a
--- /dev/null
+++ b/tests/testYAMLParser.py
@@ -0,0 +1,116 @@
+import 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'}])
diff --git a/trace.txt b/trace.txt
new file mode 100644
index 0000000..b083ccd
--- /dev/null
+++ b/trace.txt
@@ -0,0 +1,81 @@
+--> 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
+<?xml version='1.0' encoding='UTF-8'?>
+<galileo-client version="2.0"><client-info><client-id>6de4df71-17f9-43ea-9854-67f842021e05</client-id><client-version>0.1</client-version><client-mode>sync</client-mode><dongle-version major="1" minor="1" /></client-info><tracker tracker-id="E5145333EEFF"><data>JgIAAAAAAAAAAN8p9UspBQYuBi4AAJF+dQMAAAAAFBT9DxRkAAAAAOsCqAOBKlIJGh8AAAAAAAAA+8sAQkVOICAgICAgIENIRUVSUyAgICBIRUxMTyAgICAgR08gICAgICAgIB8AAAAAIAAAAAAAAAAAAAAAAAAAAAABAMDb3N3SJZJSCAEFklIA0iUAAADSJZJSCwAFAAAAAAAAAADSJZJSCwEFAAAAAwAAAA [...]
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?><galileo-server version="2.0"><server-version>
+
+</server-version><tracker tracker-id="E5145333EEFF" type="megadumpresponse"><data>JgIAAAAAAQAAAAAAAADrAqgDgSpSCRofAAAAAAAAAPvLAEJFTiAgICAgICBIT1dEWSAgICAgU1RFUEdFRUsgIFJPQ0sgT04gICAfAAAAACAAAAAAAAAAAAAAAAAAAAAAAQDDK5JSAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAUAAAAFwyuSUgLSJZJSAaQrklIEwSuSUgfDK5JSqgMAAAAAAACPAAA=</data></tracker></galileo-server>
+==> [ 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
--
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/debian-med/galileo.git
More information about the debian-med-commit
mailing list