├── MANIFEST.in ├── ant-downloader.py ├── .gitignore ├── config └── 99-antusb.rules ├── test ├── test_notif.py ├── test_master.py ├── test_wait_for_broadcast.py └── test_slave.py ├── setup.py ├── contrib └── notify-send-wrapper ├── LICENSE ├── CHANGES ├── raw2tcx.py ├── raw2string.py ├── antd ├── __init__.py ├── notif.py ├── antd.cfg ├── hw.py ├── plugin.py ├── main.py ├── tcx.py ├── cfg.py ├── connect.py ├── antfs.py ├── garmin.py └── ant.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include antd/*.cfg 2 | -------------------------------------------------------------------------------- /ant-downloader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from antd.main import downloader 3 | downloader() 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | *.raw 4 | *~ 5 | build 6 | python_ant_downloader.egg-info 7 | dist 8 | MANIFEST 9 | -------------------------------------------------------------------------------- /config/99-antusb.rules: -------------------------------------------------------------------------------- 1 | # /etc/udev/rules.d/99-antusb2.rules example 2 | SUBSYSTEM=="usb", ATTR{idVendor}=="0fcf", ATTR{idProduct}=="1008", MODE="666" 3 | -------------------------------------------------------------------------------- /test/test_notif.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import antd.plugin as plugin 4 | import antd.notif as notif 5 | import antd.cfg as cfg 6 | 7 | cfg._cfg.add_section("antd.notification") 8 | cfg._cfg.set("antd.notification", "enabled", "True") 9 | cfg.init_loggers() 10 | 11 | plugin.register_plugins( 12 | cfg.create_notification_plugin() 13 | ) 14 | 15 | files = ['file1', 'file2', 'file3'] 16 | 17 | plugin.publish_data("0xdeadbeef", "notif_connect", files) 18 | 19 | plugin.publish_data("0xdeadbeef", "notif_junk", files) 20 | plugin.publish_data("0xdeadbeef", "complete_junk", files) 21 | 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup( 6 | name = "python_ant_downloader", 7 | version = "git", 8 | author = "Braiden Kindt", 9 | author_email = "braiden@braiden.org", 10 | description = "Tools for download from wireless Garmin (ANT) GPS devices.", 11 | url = "https://github.com/braiden/python-ant-downloader", 12 | license = "BSD", 13 | keywords = "ANT Garmin GPS 405 405CX 410", 14 | packages = ["antd"], 15 | package_data = {"antd": ["*.cfg"]}, 16 | entry_points = { 17 | 'console_scripts': ['ant-downloader = antd.main:downloader'] 18 | }, 19 | install_requires = [ 20 | "distribute", 21 | "argparse", 22 | "lxml", 23 | "pyserial", 24 | "pyusb>=1.0.0a2", 25 | "requests", 26 | ], 27 | classifiers = [ 28 | "Development Status :: 4 - Beta", 29 | "Environment :: Console", 30 | "License :: OSI Approved :: BSD License", 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /test/test_master.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | import logging 5 | import time 6 | 7 | import antd.ant as ant 8 | import antd.hw as hw 9 | 10 | logging.basicConfig( 11 | level=logging.DEBUG, 12 | out=sys.stderr, 13 | format="[%(threadName)s]\t%(asctime)s\t%(levelname)s\t%(message)s") 14 | 15 | _LOG = logging.getLogger() 16 | 17 | dev = hw.UsbHardware() 18 | core = ant.Core(dev) 19 | session = ant.Session(core) 20 | try: 21 | channel = session.channels[0] 22 | network = session.networks[0] 23 | network.set_key("\x00" * 8) 24 | channel.assign(channel_type=0x30, network_number=0) 25 | channel.set_id(device_number=0, device_type_id=0, trans_type=0) 26 | channel.set_period(0x4000) 27 | channel.set_search_timeout(20) 28 | channel.set_rf_freq(40) 29 | channel.open() 30 | channel.send_broadcast("testtest") 31 | while True: 32 | _LOG.info("READ %s", channel.read(timeout=10)) 33 | finally: 34 | try: session.close() 35 | except: _LOG.warning("Caught exception while resetting system.", exc_info=True) 36 | 37 | 38 | # vim: ts=4 sts=4 et 39 | -------------------------------------------------------------------------------- /test/test_wait_for_broadcast.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | import logging 5 | 6 | import antd.ant as ant 7 | import antd.hw as hw 8 | 9 | logging.basicConfig( 10 | level=logging.DEBUG, 11 | out=sys.stderr, 12 | format="[%(threadName)s]\t%(asctime)s\t%(levelname)s\t%(message)s") 13 | 14 | _LOG = logging.getLogger() 15 | 16 | dev = hw.UsbHardware() 17 | core = ant.Core(dev) 18 | session = ant.Session(core) 19 | try: 20 | channel = session.channels[0] 21 | network = session.networks[0] 22 | network.set_key("\xa8\xa4\x23\xb9\xf5\x5e\x63\xc1") 23 | channel.assign(channel_type=0x00, network_number=0) 24 | channel.set_id(device_number=0, device_type_id=0, trans_type=0) 25 | channel.set_period(0x1000) 26 | channel.set_search_timeout(20) 27 | channel.set_rf_freq(50) 28 | channel.set_search_waveform(0x0053) 29 | channel.open() 30 | print channel.recv_broadcast(timeout=0).encode("hex") 31 | finally: 32 | try: session.close() 33 | except: _LOG.warning("Caught exception while resetting system.", exc_info=True) 34 | 35 | 36 | # vim: ts=4 sts=4 et 37 | -------------------------------------------------------------------------------- /test/test_slave.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | import logging 5 | 6 | import antd.ant as ant 7 | import antd.hw as hw 8 | 9 | logging.basicConfig( 10 | level=logging.DEBUG, 11 | out=sys.stderr, 12 | format="[%(threadName)s]\t%(asctime)s\t%(levelname)s\t%(message)s") 13 | 14 | _LOG = logging.getLogger() 15 | 16 | dev = hw.UsbHardware() 17 | core = ant.Core(dev) 18 | session = ant.Session(core) 19 | try: 20 | channel = session.channels[0] 21 | network = session.networks[0] 22 | network.set_key("\x00" * 8) 23 | channel.assign(channel_type=0x00, network_number=0) 24 | channel.set_id(device_number=0, device_type_id=0, trans_type=0) 25 | channel.set_period(0x4000) 26 | channel.set_search_timeout(4) 27 | channel.set_rf_freq(40) 28 | channel.open() 29 | _LOG.info("BROADCAST: %s", channel.recv_broadcast(timeout=0)) 30 | channel.send_acknowledged("ack") 31 | channel.send_burst("burst") 32 | channel.send_burst("burst" * 10) 33 | channel.write("write") 34 | finally: 35 | try: session.close() 36 | except: _LOG.warning("Caught exception while resetting system.", exc_info=True) 37 | 38 | 39 | # vim: ts=4 sts=4 et 40 | -------------------------------------------------------------------------------- /contrib/notify-send-wrapper: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # author: github/jat255 4 | # a little script that when called, will call ant-downloader and pop up 5 | # a notification using Gnome's notify-send depending on the outcome. 6 | 7 | # Place this file wherever you'd like 8 | LOGFILE=$HOME/transfer_results 9 | 10 | # For record keeping 11 | echo `date` > $LOGFILE 12 | 13 | # Call the script 14 | ant-downloader &>> $LOGFILE 15 | 16 | if grep -q "No available device" $LOGFILE 17 | then notify-send --urgency=critical --hint=int:transient:1 'ant-downloader: USB Stick not detected' 18 | fi 19 | 20 | if grep -q "but no data availible for download" $LOGFILE 21 | then notify-send --hint=int:transient:1 'ant-downloader: No new data for download' 22 | fi 23 | 24 | if grep -q "tcx: writing" $LOGFILE 25 | then notify-send --hint=int:transient:1 'ant-downloader: Successfully downloaded from device' 26 | fi 27 | 28 | if grep -q "tcx to Garmin Connect" $LOGFILE 29 | then notify-send --hint=int:transient:1 'ant-downloader: Data uploaded to Garmin Connect' 30 | fi 31 | 32 | exit 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Braiden Kindt. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials 14 | provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 17 | ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 26 | WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | - master 2 | - Add Creator section in tcx. by Carlroth, issue #22 3 | - cleanup setup.py, shouldn't fail when downloaded from PyPi (once released) 4 | - add dependency on distribution (to ensure pkg_resource is availible) 5 | - add dbus notifications (written by ivankelly) 6 | - added suppport for older USB ANT stick (nRF241AP1 with USB<->Serial) 7 | - fix for failed garmin update when login name != user name 8 | - plugin for email upload to Strava 9 | - 2012-02-25 10 | - setup tools, automated installer 11 | - check version# of config file, and generate warning if 12 | file might not be backward compatible 13 | - config file must exist in ~/.antd 14 | - 2012-02-19 15 | - fix bug where burst transfers may fail do to incorrect sequence 16 | numbers. (0 should only be used for first packet) 17 | - merged branch 'multidevice': multi-device suppport 18 | - old known devices dbm is incompatable, you will need to repair 19 | - some configuration updated, tcx, raw, etc now write to path 20 | which includes device serial number. It is recommended you 21 | use latest configuration file. Update if you've copied it 22 | to ~/.antd. 23 | - pairing is only accepted when not running in daemon mode. 24 | This seems to make sense because otherwise, the background 25 | process would potentially drain battery of unparied devices 26 | which are found in range. 27 | - updated antfs search code, can search for a device with specfic 28 | serial numbers. prerequisites to implementing uploads. 29 | -------------------------------------------------------------------------------- /raw2tcx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright (c) 2012, Braiden Kindt. 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions 8 | # are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # 13 | # 2. Redistributions in binary form must reproduce the above 14 | # copyright notice, this list of conditions and the following 15 | # disclaimer in the documentation and/or other materials 16 | # provided with the distribution. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 19 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 28 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | # POSSIBILITY OF SUCH DAMAGE. 30 | 31 | 32 | import sys 33 | import logging 34 | import lxml.etree as etree 35 | 36 | import antd.garmin as garmin 37 | import antd.tcx as tcx 38 | import antd.cfg as cfg 39 | 40 | cfg.init_loggers(logging.DEBUG, out=sys.stderr) 41 | 42 | if len(sys.argv) != 2: 43 | print "usage: %s " % sys.argv[0] 44 | sys.exit(1) 45 | 46 | with open(sys.argv[1]) as file: 47 | host = garmin.MockHost(file.read()) 48 | host.device_id = 0 49 | device = garmin.Device(host) 50 | runs = device.get_runs() 51 | doc = tcx.create_document(device, garmin.extract_runs(device, runs)) 52 | print etree.tostring(doc, pretty_print=True, xml_declaration=True, encoding="UTF-8") 53 | -------------------------------------------------------------------------------- /raw2string.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright (c) 2012, Braiden Kindt. 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions 8 | # are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # 13 | # 2. Redistributions in binary form must reproduce the above 14 | # copyright notice, this list of conditions and the following 15 | # disclaimer in the documentation and/or other materials 16 | # provided with the distribution. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 19 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 28 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | # POSSIBILITY OF SUCH DAMAGE. 30 | 31 | 32 | import sys 33 | import logging 34 | import lxml.etree as etree 35 | 36 | import antd.garmin as garmin 37 | import antd.tcx as tcx 38 | import antd.cfg as cfg 39 | 40 | cfg.init_loggers(logging.DEBUG, out=sys.stderr) 41 | 42 | if len(sys.argv) != 2: 43 | print "usage: %s " % sys.argv[0] 44 | sys.exit(1) 45 | 46 | with open(sys.argv[1]) as file: 47 | host = garmin.MockHost(file.read()) 48 | #device = garmin.Device(host) 49 | for idx, pkt in enumerate(host.reader): 50 | if pkt: 51 | pid, length, data = garmin.unpack(pkt) 52 | data = "\n".join([(d if not idx else (" " * 23) + d) for idx, d in enumerate(garmin.chunk(data.encode("hex"), 32))]) 53 | print "%04d pid=%04x len=%04x %s" % (idx, pid, length, data) 54 | else: 55 | print "%04d EOF" % idx 56 | -------------------------------------------------------------------------------- /antd/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012, Braiden Kindt. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions 6 | # are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # 2. Redistributions in binary form must reproduce the above 12 | # copyright notice, this list of conditions and the following 13 | # disclaimer in the documentation and/or other materials 14 | # provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 17 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 26 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | # POSSIBILITY OF SUCH DAMAGE. 28 | 29 | 30 | import logging 31 | import os 32 | 33 | import antd.ant as ant 34 | import antd.antfs as antfs 35 | import antd.hw as hw 36 | import antd.garmin as garmin 37 | import antd.tcx as tcx 38 | import antd.cfg as cfg 39 | import antd.connect as connect 40 | 41 | Host = antfs.Host 42 | Beacon = antfs.Beacon 43 | Core = ant.Core 44 | Session = ant.Session 45 | Channel = ant.Channel 46 | Network = ant.Network 47 | Device = garmin.Device 48 | 49 | AntError = ant.AntError 50 | AntTimeoutError = ant.AntTimeoutError 51 | AntTxFailedError = ant.AntTxFailedError 52 | AntChannelClosedError = ant.AntChannelClosedError 53 | DeviceNotSupportedError = garmin.DeviceNotSupportedError 54 | 55 | __all__ = [ 56 | "UsbAntFsHost", 57 | "Host", 58 | "Beacon", 59 | "Core", 60 | "Session", 61 | "Channel", 62 | "Network", 63 | "Device", 64 | "AntError", 65 | "AntTimeoutError", 66 | "AntTxFailedError", 67 | "AntChannelClosedError", 68 | "DeviceNotSupportedError", 69 | ] 70 | 71 | 72 | # vim: ts=4 sts=4 et 73 | -------------------------------------------------------------------------------- /antd/notif.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012, Ivan Kelly 2 | # 2013, Braiden Kindt 3 | # All rights reserved. 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions 7 | # are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright 10 | # notice, this list of conditions and the following disclaimer. 11 | # 12 | # 2. Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following 14 | # disclaimer in the documentation and/or other materials 15 | # provided with the distribution. 16 | # 17 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 18 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 20 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 21 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 22 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 23 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 26 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 27 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | # POSSIBILITY OF SUCH DAMAGE. 29 | 30 | import os.path 31 | import antd.plugin as plugin 32 | import logging 33 | 34 | _log = logging.getLogger("antd.notif") 35 | 36 | import pynotify 37 | 38 | class NotifPlugin(plugin.Plugin): 39 | _enabled = True 40 | 41 | def __init__(self): 42 | self._enabled = True 43 | if not pynotify.init("python-ant-downloader"): 44 | _log.error("Couldn't enabled pynotify, disabling") 45 | self._enabled = False 46 | 47 | def data_available(self, device_sn, format, files): 48 | if not self._enabled: 49 | return files 50 | 51 | try: 52 | filenames = map(os.path.basename, files) 53 | if format == "notif_connect": 54 | n = pynotify.Notification( 55 | "Ant+ Downloader", 56 | "Uploaded files [%s] to Garmin Connect" % ", ".join(filenames), 57 | "notification-message-im") 58 | n.show() 59 | # elif format == "tcx": 60 | # n = pynotify.Notification( 61 | # "Ant+ Downloader", 62 | # "Files [%s] processed" % ", ".join(filenames), 63 | # "notification-message-im") 64 | # n.show() 65 | finally: 66 | return files 67 | -------------------------------------------------------------------------------- /antd/antd.cfg: -------------------------------------------------------------------------------- 1 | [antd] 2 | version = 2 3 | ; how many times should download be tried before 4 | ; failing. this option mainly exists so that the 5 | ; deamon will abort instead infinitely retrying 6 | ; download forever. Scanning for devices is passive 7 | ; (does not impact battery) but downloading drains 8 | ; the battery. Each retry is attempted on a randomly 9 | ; selected frequency, so if you're in a noisy RF 10 | ; environment increasing this value may help. 11 | retry = 3 12 | ; where to write raw download data. raw data 13 | ; contains garmin physical layer packets appended 14 | ; to each other in format: uint16_t packet_type, 15 | ; uint16_t data_length, char[data_length] data. 16 | ; this data is useful for debugging and implementing 17 | ; support for new devices. A best effort is made 18 | ; to write this file even if the device is unsupported. 19 | ; Unless you're reporting bugs implementing new 20 | ; devices, you probably don't need to look at these 21 | ; files, but they are always written 22 | raw_output_dir = ~/.antd/%%(device_id)s/raw 23 | ; set to true to delete from data from device after downloading 24 | delete_from_device = False 25 | 26 | [antd.tcx] 27 | ; plugin which writes TCX files, you probably 28 | ; want to keep enabled. 29 | enabled = True 30 | ; where tcx files are written 31 | tcx_output_dir = ~/.antd/%%(device_id)s/tcx 32 | ; file used for recovery, keeps track of 33 | ; all raw files which still need to be 34 | ; converted to tcx. if a raw file failes 35 | ; it will be automatically retried until 36 | ; its sucessfuly, or cache is deleted. 37 | cache = ~/.antd/raw-to-tcx-queue.txt 38 | 39 | [antd.connect] 40 | ; true to enable uploading in general 41 | enabled = False 42 | ; Garmin Connect username / password 43 | username = 44 | password = 45 | ; file used to keep track of all tcx which 46 | ; are pending upload. files in the cache 47 | ; will have upload re-attempted until successful 48 | ; or until cache is deleted. 49 | cache = ~/.antd/garmin-connect-upload-queue.txt 50 | 51 | [antd.gupload] 52 | ; Use garmin-uploader avaliable at https://github.com/La0/garmin-uploader to upload to Garmin Connect 53 | ; garmin-uploader must be installed on the system and avaliable as gupload command in the shell 54 | enabled = False 55 | username= 56 | password= 57 | cache = ~/.antd/gupload-upload-queue.txt 58 | 59 | [antd.strava] 60 | enabled = False 61 | ; smtp server for uploading to strava 62 | smtp_server = smtp.gmail.com 63 | smtp_port = 587 64 | smtp_username = 65 | smtp_password = 66 | 67 | [antd.logging] 68 | antd = DEBUG 69 | antd.trace = INFO ; noisy: DEBUG to log all usb packets 70 | antd.ant = INFO ; noisy: DEBUG to log all ant session commands 71 | ;antd.connect = INFO 72 | ;antd.antfs = INFO 73 | ;antd.garmin = INFO 74 | ;antAgent.tcx = INFO 75 | 76 | [antd.antfs] 77 | ; where to save keys when pairing with a device 78 | auth_pairing_keys = ~/.antd/known_devices.cfg 79 | ; ANT channel parameters, should not need to edit 80 | search_network_key = a8a423b9f55e63c1 ; ant-fs 81 | search_freq = 50 ; 2450mhz 82 | search_period = 0x1000 ; 8hz 83 | search_timeout = 255 ; infinite 84 | search_waveform = 0x53 ; ?? undocumented, copied from windows ?? 85 | transport_freq = 3,7,15,20,25,29,34,40,45,49,54,60,65,70,75,80 86 | transport_period = 4 ; 8hz 87 | transport_timeout = 2 ; 5 seconds 88 | 89 | [antd.ant] 90 | ; larger timeouts and retry may help if RF reception is poor 91 | default_read_timeout = 5 ; seconds 92 | default_write_timeout = 5 ; seconds 93 | default_retry = 9 ; applies only to retryable errors 94 | 95 | [antd.hw] 96 | ; usb device config, should not need to edit 97 | id_vendor = 0x0fcf 98 | id_product = 0x1008 99 | bulk_endpoint = 1 100 | ; ap1 (older devices, may need to edit tty) 101 | serial_device = /dev/ttyUSB0 102 | 103 | [antd.notification] 104 | ; True to enable notification when tcx files are uploaded 105 | ; Requires pynotify 106 | enabled = False 107 | -------------------------------------------------------------------------------- /antd/hw.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012, Braiden Kindt. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions 6 | # are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # 2. Redistributions in binary form must reproduce the above 12 | # copyright notice, this list of conditions and the following 13 | # disclaimer in the documentation and/or other materials 14 | # provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 17 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 26 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | # POSSIBILITY OF SUCH DAMAGE. 28 | 29 | 30 | import usb.core 31 | import usb.util 32 | import errno 33 | import logging 34 | import struct 35 | import array 36 | 37 | _log = logging.getLogger("antd.usb") 38 | 39 | class UsbHardware(object): 40 | """ 41 | Provides access to USB based ANT chips. 42 | Communication is sent of a USB endpoint. 43 | USB based hardware with a serial bridge 44 | (e.g. nRF24AP1 + FTDI) is not supported. 45 | """ 46 | 47 | def __init__(self, id_vendor=0x0fcf, id_product=0x1008, ep=1): 48 | for dev in usb.core.find(idVendor=id_vendor, idProduct=id_product, find_all=True): 49 | try: 50 | dev.set_configuration() 51 | usb.util.claim_interface(dev, 0) 52 | self.dev = dev 53 | self.ep = ep 54 | break 55 | except IOError as (err, msg): 56 | if err == errno.EBUSY or "Device or resource busy" in msg: #libusb10 or libusb01 57 | _log.info("Found device with vid(0x%04x) pid(0x%04x), but interface already claimed.", id_vendor, id_product) 58 | else: 59 | raise 60 | else: 61 | raise NoUsbHardwareFound(errno.ENOENT, "No available device matching vid(0x%04x) pid(0x%04x)." % (id_vendor, id_product)) 62 | 63 | def close(self): 64 | usb.util.release_interface(self.dev, 0) 65 | 66 | def write(self, data, timeout): 67 | transfered = self.dev.write(self.ep | usb.util.ENDPOINT_OUT, data, timeout=timeout) 68 | if transfered != len(data): 69 | raise IOError(errno.EOVERFLOW, "Write too large, len(data) > wMaxPacketSize not supported.") 70 | 71 | def read(self, timeout): 72 | return self.dev.read(self.ep | usb.util.ENDPOINT_IN, 16384, timeout=timeout) 73 | 74 | 75 | class NoUsbHardwareFound(IOError): pass 76 | 77 | class SerialHardware(object): 78 | 79 | def __init__(self, dev="/dev/ttyUSB0", baudrate=115200): 80 | import serial 81 | self.dev = serial.Serial(port=dev, baudrate=baudrate, timeout=1) 82 | 83 | def close(self): 84 | self.dev.close() 85 | 86 | def write(self, data, timeout): 87 | arr = array.array("B", data) 88 | self.dev.write(arr.tostring()) 89 | 90 | def read(self, timeout): 91 | # attempt to read the start of packet 92 | header = self.dev.read(2) 93 | if header: 94 | sync, length = struct.unpack("2B", header) 95 | if sync not in (0xa4, 0xa5): 96 | raise IOError(-1, "ANT packet did not start with expected SYNC packet. Remove USB device and try again?") 97 | length += 2 # checksum & msg_id 98 | data = self.dev.read(length) 99 | if len(data) != length: 100 | raise IOError(-1, "ANT packet short?") 101 | return array.array("B", header + data) 102 | else: 103 | raise IOError(errno.ETIMEDOUT, "Timeout") 104 | 105 | # vim: ts=4 sts=4 et 106 | -------------------------------------------------------------------------------- /antd/plugin.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012, Braiden Kindt. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions 6 | # are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # 2. Redistributions in binary form must reproduce the above 12 | # copyright notice, this list of conditions and the following 13 | # disclaimer in the documentation and/or other materials 14 | # provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 17 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 26 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | # POSSIBILITY OF SUCH DAMAGE. 28 | 29 | 30 | import logging 31 | import os 32 | 33 | _log = logging.getLogger("antd.plugin") 34 | _plugins = [] 35 | 36 | class Plugin(object): 37 | """ 38 | A plugin receives notifications when new data 39 | is available, it can consume the data or transform it. 40 | TCX file generation, and garmin connect upload are 41 | both implementations of plugin. You can implement 42 | your own to produce new file formats or upload somewhere. 43 | """ 44 | 45 | def data_available(self, device_sn, format, files): 46 | """ 47 | Notification that data is available, this could 48 | be raw packet data from device, or higher level 49 | data generated by other plugins, e.g. TCX. 50 | Return: files which were successfully processed. 51 | """ 52 | pass 53 | 54 | 55 | class PluginQueue(object): 56 | """ 57 | File based queue representing unprocessed 58 | files which were not handled the a plugin. 59 | """ 60 | 61 | def __init__(self, plugin): 62 | try: self.queue_file_name = plugin.cache 63 | except AttributeError: self.queue_file_name = None 64 | self.queue = [] 65 | 66 | def load_queue(self): 67 | if self.queue_file_name and os.path.isfile(self.queue_file_name): 68 | with open(self.queue_file_name, "r") as file: 69 | lines = file.read().splitlines() 70 | self.queue = [] 71 | for line in lines: 72 | device_sn, format, file = line.split(",") 73 | if os.path.isfile(file): 74 | self.queue.append((int(device_sn), format, file)) 75 | else: 76 | _log.warning("File pending processing, but disappeared. %s", file) 77 | 78 | def save_queue(self): 79 | if self.queue_file_name and self.queue: 80 | with open(self.queue_file_name, "w") as file: 81 | file.writelines("%d,%s,%s\n" % e for e in self.queue) 82 | elif self.queue_file_name and os.path.isfile(self.queue_file_name): 83 | os.unlink(self.queue_file_name) 84 | 85 | def add_to_queue(self, device_sn, format, files): 86 | for file in files: 87 | self.queue.append((device_sn, format, file)) 88 | 89 | 90 | def register_plugins(*plugins): 91 | _plugins.extend(p for p in plugins if p is not None) 92 | for plugin in plugins: 93 | try: plugin and recover_and_publish_data(plugin) 94 | except Exception: _log.warning("Plugin failed. %s", plugin, exc_info=True) 95 | 96 | def recover_and_publish_data(plugin): 97 | q = PluginQueue(plugin) 98 | q.load_queue() 99 | if q.queue: 100 | try: 101 | _log.debug("Attempting to reprocess failed files.") 102 | for device_sn, format, file in list(q.queue): 103 | if plugin.data_available(device_sn, format, [file]): 104 | q.queue.remove((device_sn, format, file)) 105 | except Exception: 106 | _log.warning("Plugin failed. %s", plugin, exc_info=True) 107 | finally: 108 | q.save_queue() 109 | 110 | def publish_data(device_sn, format, files): 111 | for plugin in _plugins: 112 | try: 113 | processed = plugin.data_available(device_sn, format, files) 114 | not_processed = [f for f in files if f not in processed] 115 | except Exception: 116 | processed = [] 117 | not_processed = files 118 | _log.warning("Plugin failed. %s", plugin, exc_info=True) 119 | finally: 120 | q = PluginQueue(plugin) 121 | q.load_queue() 122 | q.add_to_queue(device_sn, format, not_processed) 123 | q.save_queue() 124 | 125 | 126 | # vim: ts=4 sts=4 et 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Ant Downloader 2 | 3 | Tools for extracting data from Garmin wireless (ANT) GPS devices. The project goal is to support downloading data from GPS unit and upload to Garmin Connect. It doesn't support workout, profile, or activity uploads like the Windows "Garmin ANT Agent." 4 | 5 | This software implements the [Garmin Device Interface Spec](http://www8.garmin.com/support/commProtocol.html) over an [ANT-FS](http://www.thisisant.com) transport. In theory it should work with any device implementing this stack. Early wireless Garmin devices should be supported, but newer hardware uses a different protocol. See "Supported Devices" below. 6 | 7 | The software can be run as either a daemon or on-demand. In deamon mode it automatically saves TCX files to a configured directory whenever a paired device is within range and has new data. In on-demand mode the program just downloads once and terminates. The software also supports automatic upload to Garmin Connect. 8 | 9 | ## Getting Help 10 | 11 | Issues Tracker: https://github.com/braiden/python-ant-downloader/issues 12 | 13 | ## Supported Devices 14 | 15 | So far this software software has been reported to work with: 16 | 17 | * 405 18 | * 405CX 19 | * 410 20 | 21 | ## Unsupported Devices 22 | 23 | * 610 24 | * 310 25 | * FR60 26 | * 910XT 27 | 28 | These devices (and probably anything newer) appear to implement ANT-FS instead of "Garmin Device Interface API". You can try this: https://github.com/Tigge/Garmin-Forerunner-610-Extractor. 29 | 30 | ## Installing 31 | 32 | ### Easy Install (stable) 33 | 34 | Make sure your system has python, pip, and libusb-1.0: 35 | 36 | Debian/Ubuntu: 37 | sudo apt-get install python-pip libusb-1.0-0 38 | Fedora: 39 | yum install python-pip libusb 40 | 41 | You may also want to install python-lxml. If you skip this step pip will need to build from source (which requires libxml2 libxslt1 dev packages): 42 | 43 | Debian/Ubuntu: 44 | sudo apt-get install python-lxml 45 | Fedora: 46 | sudo yum install python-lxml 47 | 48 | Once prerequisites are installed you can install python-ant-downloader from PyPi: 49 | 50 | sudo pip install python-ant-downloader 51 | 52 | ### Manual Install (stable/unstable) 53 | 54 | You can either clone the git project: 55 | 56 | git clone git://github.com/braiden/python-ant-downloader.git 57 | 58 | Or download a stable build from [PyPi](http://pypi.python.org/pypi/python_ant_downloader) or [Github tags](https://github.com/braiden/python-ant-downloader/tags) 59 | 60 | ##### Prerequisites 61 | 62 | * Python 2.6+ 63 | * [pyusb 1.0](https://github.com/walac/pyusb) - latest version from github is recommended. 0.4 will not work. 64 | * [request](http://docs.python-requests.org/en/latest/) - only if you enable upload to garmin connect 65 | * [argparse](http://pypi.python.org/pypi/argparse) - if < python 2.7 66 | * [lxml](http://pypi.python.org/pypi/lxml) 67 | * [setuptools](http://pypi.python.org/pypi/setuptools) 68 | * pyserial (required for older hardware revisions of USB ANT Stick) 69 | 70 | On Ubuntu most of these dependencies can be satisfied with: 71 | 72 | apt-get install python python-lxml python-pkg-resources python-requests python-serial 73 | 74 | But, you will still need to download pyusb from github or PyPi. 75 | 76 | Fedora: 77 | sudo yum install python python-lxml pyusb 78 | sudo easy_install poster 79 | 80 | ## Running 81 | 82 | $ ant-downloader --help 83 | 84 | usage: ant-downloader [-h] [--config f] [--daemon] [--verbose] 85 | optional arguments: 86 | -h, --help show this help message and exit 87 | --config f, -c f use provided configuration, defaults ~/.antd/antd.cfg, 88 | --daemon, -d run in continuous search mode downloading data from any 89 | availible devices, WILL NOT PAIR WITH NEW DEVICES 90 | --verbose, -v enable all debugging output, NOISY: see config file to 91 | selectively enable loggers 92 | 93 | ### First Time 94 | 95 | Make sure you have permission to access the USB device. Add a text file with one of the following to /etc/udev/rules.d/99-garmin.rules. 96 | 97 | On Ubuntu 10.04 (or other other older distros): 98 | 99 | SUBSYSTEM=="usb", SYSFS{idVendor}=="0fcf", SYSFS{idProduct}=="1008", MODE:="666" 100 | 101 | On Ubuntu 12.04, Fedora 19 (or other distros running newer udev): 102 | 103 | SUBSYSTEM=="usb", ATTR{idVendor}=="0fcf", ATTR{idProduct}=="1008", MODE:="666" 104 | 105 | The first time you run the program it will need to pair with your GPS device. Make sure the the GPS unit is awake (press a button), and make sure pairing is enabled. Then just run ant-downloader. When prompted accept the pairing request on your GPS device. Once request is accepted a key is saved and you should not need to pair again. 106 | 107 | You may also choose to enable "Force Downloads" on your device. This will cause all old data to be downloaded. WARNING, It will also upload all data to Garmin Connect. 108 | 109 | Also the device must not be claimed by the usbserial kernel module. 110 | if you get an error and dmesg says 111 | 112 | usb 3-1.2: usbfs: interface 0 claimed by usbserial_generic while 'ant-downloader' sets config #1 113 | 114 | try unloading the 'usbserial' kernel module. 115 | 116 | ### Configuration 117 | 118 | See antd.cfg from configuration options including where files are saved, and Garmin Connect login details. The file will be created in ~/.antd the first time you run the program. 119 | -------------------------------------------------------------------------------- /antd/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright (c) 2012, Braiden Kindt. 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions 8 | # are met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # 13 | # 2. Redistributions in binary form must reproduce the above 14 | # copyright notice, this list of conditions and the following 15 | # disclaimer in the documentation and/or other materials 16 | # provided with the distribution. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 19 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 28 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | # POSSIBILITY OF SUCH DAMAGE. 30 | 31 | def downloader(): 32 | import logging 33 | import sys 34 | import time 35 | import struct 36 | import argparse 37 | import os 38 | import shutil 39 | import lxml.etree as etree 40 | import antd 41 | 42 | # command line 43 | parser = argparse.ArgumentParser() 44 | parser.add_argument("--config", "-c", nargs=1, metavar="f", 45 | help="use provided configuration, defaults to ~/.antd/antd.cfg") 46 | parser.add_argument("--daemon", "-d", action="store_const", const=True, 47 | help="run in continuous search mode downloading data from any available devices, WILL NOT PAIR WITH NEW DEVICES") 48 | parser.add_argument("--verbose", "-v", action="store_const", const=True, 49 | help="enable all debugging output, NOISY: see config file to selectively enable loggers") 50 | parser.add_argument("--force", "-f", action="store_const", const=True, 51 | help="force a connection with device even if it claims no data available. FOR DEBUG ONLY.") 52 | args = parser.parse_args() 53 | 54 | # load configuration 55 | cfg = args.config[0] if args.config else None 56 | if not antd.cfg.read(cfg): 57 | print "unable to read config file." 58 | parser.print_usage() 59 | sys.exit(1) 60 | 61 | # enable debug if -v used 62 | if args.verbose: antd.cfg.init_loggers(logging.DEBUG) 63 | _log = logging.getLogger("antd") 64 | 65 | # register plugins, add uploaders and file converters here 66 | antd.plugin.register_plugins( 67 | antd.cfg.create_garmin_connect_plugin(), 68 | antd.cfg.create_strava_plugin(), 69 | antd.cfg.create_gupload_plugin(), 70 | antd.cfg.create_tcx_plugin(), 71 | antd.cfg.create_notification_plugin() 72 | ) 73 | 74 | # create an ANTFS host from configuration 75 | host = antd.cfg.create_antfs_host() 76 | try: 77 | failed_count = 0 78 | while failed_count <= antd.cfg.get_retry(): 79 | try: 80 | _log.info("Searching for ANT devices.") 81 | # in daemon mode we do not attempt to pair with unknown devices 82 | # (it requires gps watch to wake up and would drain battery of 83 | # any un-paired devices in range.) 84 | beacon = host.search(include_unpaired_devices=not args.daemon, 85 | include_devices_with_no_data=args.force or not args.daemon) 86 | if beacon and (beacon.data_available or args.force): 87 | _log.info("Device has data. Linking.") 88 | host.link() 89 | _log.info("Pairing with device.") 90 | client_id = host.auth(pair=not args.daemon) 91 | raw_name = time.strftime("%Y%m%d-%H%M%S.raw") 92 | raw_full_path = antd.cfg.get_path("antd", "raw_output_dir", raw_name, 93 | {"device_id": hex(host.device_id)}) 94 | with open(raw_full_path, "w") as file: 95 | _log.info("Saving raw data to %s.", file.name) 96 | # create a garmin device, and initialize its 97 | # ant initialize its capabilities. 98 | dev = antd.Device(host) 99 | antd.garmin.dump(file, dev.get_product_data()) 100 | # download runs 101 | runs = dev.get_runs() 102 | antd.garmin.dump(file, runs) 103 | if antd.cfg.get_delete_from_device(): dev.delete_runs() 104 | _log.info("Closing session.") 105 | host.disconnect() 106 | _log.info("Excuting plugins.") 107 | # dispatcher data to plugins 108 | antd.plugin.publish_data(host.device_id, "raw", [raw_full_path]) 109 | elif not args.daemon: 110 | _log.info("Found device, but no data available for download.") 111 | if not args.daemon: break 112 | failed_count = 0 113 | except antd.AntError: 114 | _log.warning("Caught error while communicating with device, will retry.", exc_info=True) 115 | failed_count += 1 116 | finally: 117 | try: host.close() 118 | except Exception: _log.warning("Failed to cleanup resources on exist.", exc_info=True) 119 | 120 | 121 | # vim: ts=4 sts=4 et 122 | -------------------------------------------------------------------------------- /antd/tcx.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012, Braiden Kindt. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions 6 | # are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # 2. Redistributions in binary form must reproduce the above 12 | # copyright notice, this list of conditions and the following 13 | # disclaimer in the documentation and/or other materials 14 | # provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 17 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 26 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | # POSSIBILITY OF SUCH DAMAGE. 28 | 29 | 30 | import logging 31 | import time 32 | import os 33 | import glob 34 | import shutil 35 | import lxml.etree as etree 36 | import lxml.builder as builder 37 | 38 | import antd.plugin as plugin 39 | import antd.garmin as garmin 40 | 41 | _log = logging.getLogger("antd.tcx") 42 | 43 | E = builder.ElementMaker(nsmap={ 44 | None: "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2", 45 | "ext": "http://www.garmin.com/xmlschemas/ActivityExtension/v2", 46 | "xsi": "http://www.w3.org/2001/XMLSchema-instance", 47 | }) 48 | 49 | X = builder.ElementMaker(namespace="http://www.garmin.com/xmlschemas/ActivityExtension/v2") 50 | 51 | class TcxPlugin(plugin.Plugin): 52 | 53 | tcx_output_dir = "." 54 | 55 | def data_available(self, device_sn, format, files): 56 | if "raw" != format: return files 57 | processed = [] 58 | result = [] 59 | try: 60 | for file in files: 61 | _log.info("TcxPlugin: processing %s.", file) 62 | try: 63 | dir = self.tcx_output_dir % {"device_id": hex(device_sn)} 64 | if not os.path.exists(dir): os.makedirs(dir) 65 | files = export_tcx(device_sn, file, dir) 66 | result.extend(files) 67 | processed.append(file) 68 | except Exception: 69 | _log.warning("Failed to process %s. Maybe a datatype is unimplemented?", file, exc_info=True) 70 | plugin.publish_data(device_sn, "tcx", result) 71 | finally: 72 | return processed 73 | 74 | 75 | def format_time(gmtime): 76 | return time.strftime("%Y-%m-%dT%H:%M:%SZ", gmtime) 77 | 78 | def format_intensity(intensity): 79 | if intensity == 1: 80 | return "Resting" 81 | else: 82 | return "Active" 83 | 84 | def format_trigger_method(trigger_method): 85 | if trigger_method == 0: return "Manual" 86 | elif trigger_method == 1: return "Distance" 87 | elif trigger_method == 2: return "Location" 88 | elif trigger_method == 3: return "Time" 89 | elif trigger_method == 4: return "HeartRate" 90 | 91 | def format_sport(sport): 92 | if sport == 0: return "Running" 93 | elif sport == 1: return "Biking" 94 | elif sport == 2: return "Other" 95 | 96 | def format_sensor_state(sensor): 97 | if sensor: return "Present" 98 | else: return "Absent" 99 | 100 | def create_wpt(wpt, sport_type): 101 | elements = [E.Time(format_time(wpt.time.gmtime))] 102 | if wpt.posn.valid: 103 | elements.extend([ 104 | E.Position( 105 | E.LatitudeDegrees(str(wpt.posn.deglat)), 106 | E.LongitudeDegrees(str(wpt.posn.deglon)))]) 107 | if wpt.alt is not None: 108 | elements.append(E.AltitudeMeters(str(wpt.alt))) 109 | if wpt.distance is not None: 110 | elements.append(E.DistanceMeters(str(wpt.distance))) 111 | if wpt.heart_rate: 112 | elements.append(E.HeartRateBpm(E.Value(str(wpt.heart_rate)))) 113 | if wpt.cadence is not None and sport_type != 0: 114 | elements.append(E.Cadence(str(wpt.cadence))) 115 | #elements.append(E.SensorState(format_sensor_state(wpt.sensor))) 116 | if wpt.cadence is not None and sport_type == 0: 117 | elements.append(E.Extensions(X.TPX(X.RunCadence(str(wpt.cadence))))) 118 | #if len(elements) > 1: 119 | return E.Trackpoint(*elements) 120 | 121 | def create_lap(lap, sport_type): 122 | elements = [ 123 | E.TotalTimeSeconds("%0.2f" % (lap.total_time / 100.)), 124 | E.DistanceMeters(str(lap.total_dist)), 125 | E.MaximumSpeed(str(lap.max_speed)), 126 | E.Calories(str(lap.calories))] 127 | if lap.avg_heart_rate or lap.max_heart_rate: 128 | elements.extend([ 129 | E.AverageHeartRateBpm(E.Value(str(lap.avg_heart_rate))), 130 | E.MaximumHeartRateBpm(E.Value(str(lap.max_heart_rate)))]) 131 | elements.append( 132 | E.Intensity(format_intensity(lap.intensity))) 133 | if lap.avg_cadence is not None and sport_type != 0: 134 | elements.append( 135 | E.Cadence(str(lap.avg_cadence))) 136 | elements.append(E.TriggerMethod(format_trigger_method(lap.trigger_method))) 137 | wpts = [el for el in (create_wpt(w, sport_type) for w in lap.wpts) if el is not None] 138 | if wpts: 139 | elements.append(E.Track(*wpts)) 140 | if lap.avg_cadence is not None and sport_type == 0: 141 | elements.append(E.Extensions(X.LX(X.AvgRunCadence(str(lap.avg_cadence))))) 142 | return E.Lap( 143 | {"StartTime": format_time(lap.start_time.gmtime)}, 144 | *elements) 145 | 146 | def create_creator(device): 147 | major = device.device_id.software_version / 100 148 | minor = device.device_id.software_version % 100 149 | return E.Creator({"{http://www.w3.org/2001/XMLSchema-instance}type": "Device_t"}, 150 | E.Name("".join(device.device_id.description)), 151 | E.UnitId(str(device.stream.device_id)), 152 | E.ProductID(str(device.device_id.product_id)), 153 | E.Version(E.VersionMajor(str(major)), 154 | E.VersionMinor(str(minor)), 155 | E.BuildMajor("0"), 156 | E.BuildMinor("0"))) 157 | 158 | 159 | def create_activity(device, run): 160 | laps = list(create_lap(l, run.sport_type) for l in run.laps) 161 | return E.Activity( 162 | {"Sport": format_sport(run.sport_type)}, 163 | E.Id(format_time(run.time.gmtime)), 164 | *(laps + [create_creator(device)])) 165 | 166 | def create_document(device, runs): 167 | doc = E.TrainingCenterDatabase( 168 | E.Activities( 169 | *list(create_activity(device, r) for r in runs))) 170 | return doc 171 | 172 | def export_tcx(device_sn, raw_file_name, output_dir): 173 | """ 174 | Given a garmin raw packet dump, tcx to specified output directory. 175 | """ 176 | with open(raw_file_name) as file: 177 | result = [] 178 | host = garmin.MockHost(file.read()) 179 | host.device_id = device_sn 180 | device = garmin.Device(host) 181 | run_pkts = device.get_runs() 182 | runs = garmin.extract_runs(device, run_pkts) 183 | for run in runs: 184 | tcx_name = time.strftime("%Y%m%d-%H%M%S.tcx", run.time.gmtime) 185 | tcx_full_path = os.path.sep.join([output_dir, tcx_name]) 186 | _log.info("tcx: writing %s -> %s.", os.path.basename(raw_file_name), tcx_full_path) 187 | with open(tcx_full_path, "w") as file: 188 | doc = create_document(device, [run]) 189 | file.write(etree.tostring(doc, pretty_print=True, xml_declaration=True, encoding="UTF-8")) 190 | result.append(tcx_full_path) 191 | return result 192 | 193 | 194 | # vim: ts=4 sts=4 et 195 | -------------------------------------------------------------------------------- /antd/cfg.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012, Braiden Kindt. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions 6 | # are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # 2. Redistributions in binary form must reproduce the above 12 | # copyright notice, this list of conditions and the following 13 | # disclaimer in the documentation and/or other materials 14 | # provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 17 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 26 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | # POSSIBILITY OF SUCH DAMAGE. 28 | 29 | 30 | import ConfigParser 31 | import os 32 | import binascii 33 | import logging 34 | import sys 35 | import pkg_resources 36 | import logging 37 | 38 | _log = logging.getLogger("antd.cfg") 39 | _cfg = ConfigParser.SafeConfigParser() 40 | 41 | CONFIG_FILE_VERSION = 2 42 | DEFAULT_CONFIG_LOCATION = os.path.expanduser("~/.antd/antd.cfg") 43 | 44 | def write_default_config(target): 45 | dirname = os.path.dirname(target) 46 | if not os.path.exists(dirname): os.makedirs(dirname) 47 | with open(target, "w") as file: 48 | file.write(pkg_resources.resource_string(__name__, "antd.cfg")) 49 | 50 | def read(file=None): 51 | if file is None: 52 | file = DEFAULT_CONFIG_LOCATION 53 | if not os.path.isfile(file): 54 | # copy the template configuration file to users .antd directory 55 | write_default_config(file) 56 | read = _cfg.read([file]) 57 | if read: 58 | # config file read successfully, setup logger 59 | _log.setLevel(logging.WARNING) 60 | init_loggers() 61 | # check for version mismatch 62 | try: version = _cfg.getint("antd", "version") 63 | except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): version = -1 64 | if version != CONFIG_FILE_VERSION: 65 | new_file = DEFAULT_CONFIG_LOCATION + ".%d" % CONFIG_FILE_VERSION 66 | write_default_config(new_file) 67 | _log.warning("Config file version does not match expected version (%d).", CONFIG_FILE_VERSION) 68 | _log.warning("If you have issues recommended you replace your configuration with %s", new_file) 69 | _log.warning("Set [antd] version=%d in your current config file to disable this warning.", CONFIG_FILE_VERSION) 70 | return read 71 | 72 | def init_loggers(force_level=None, out=sys.stdin): 73 | level = force_level if force_level is not None else logging.ERROR 74 | logging.basicConfig( 75 | level=level, 76 | out=out, 77 | format="[%(threadName)s]\t%(asctime)s\t%(levelname)s\t%(message)s") 78 | try: 79 | for logger, log_level in _cfg.items("antd.logging"): 80 | level = force_level if force_level is not None else logging.getLevelName(log_level) 81 | logging.getLogger(logger).setLevel(level) 82 | except ConfigParser.NoSectionError: 83 | pass 84 | 85 | def create_hardware(): 86 | import antd.hw as hw 87 | try: 88 | id_vendor = int(_cfg.get("antd.hw", "id_vendor"), 0) 89 | id_product = int(_cfg.get("antd.hw", "id_product"), 0) 90 | bulk_endpoint = int(_cfg.get("antd.hw", "bulk_endpoint"), 0) 91 | return hw.UsbHardware(id_vendor, id_product, bulk_endpoint) 92 | except hw.NoUsbHardwareFound: 93 | _log.warning("Failed to find Garmin nRF24AP2 (newer) USB Stick.", exc_info=True) 94 | _log.warning("Looking for nRF24AP1 (older) Serial USB Stick.") 95 | tty = _cfg.get("antd.hw", "serial_device") 96 | return hw.SerialHardware(tty, 115200) 97 | 98 | def create_ant_core(): 99 | import antd.ant as ant 100 | return ant.Core(create_hardware()) 101 | 102 | def create_ant_session(): 103 | import antd.ant as ant 104 | session = ant.Session(create_ant_core()) 105 | session.default_read_timeout = int(_cfg.get("antd.ant", "default_read_timeout"), 0) 106 | session.default_write_timeout = int(_cfg.get("antd.ant", "default_write_timeout"), 0) 107 | session.default_retry = int(_cfg.get("antd.ant", "default_retry"), 0) 108 | return session 109 | 110 | def create_antfs_host(): 111 | import antd.antfs as antfs 112 | keys_file = _cfg.get("antd.antfs", "auth_pairing_keys") 113 | keys_file = os.path.expanduser(keys_file) 114 | keys_dir = os.path.dirname(keys_file) 115 | if not os.path.exists(keys_dir): os.makedirs(keys_dir) 116 | keys = antfs.KnownDeviceDb(keys_file) 117 | host = antfs.Host(create_ant_session(), keys) 118 | host.search_network_key = binascii.unhexlify(_cfg.get("antd.antfs", "search_network_key")) 119 | host.search_freq = int(_cfg.get("antd.antfs", "search_freq"), 0) 120 | host.search_period = int(_cfg.get("antd.antfs", "search_period"), 0) 121 | host.search_timeout = int(_cfg.get("antd.antfs", "search_timeout"), 0) 122 | host.search_waveform = int(_cfg.get("antd.antfs", "search_waveform"), 0) 123 | host.transport_freqs = [int(s, 0) for s in _cfg.get("antd.antfs", "transport_freq").split(",")] 124 | host.transport_period = int(_cfg.get("antd.antfs", "transport_period"), 0) 125 | host.transport_timeout = int(_cfg.get("antd.antfs", "transport_timeout"), 0) 126 | return host 127 | 128 | def create_garmin_connect_plugin(): 129 | try: 130 | if _cfg.getboolean("antd.connect", "enabled"): 131 | import antd.connect as connect 132 | client = connect.GarminConnect() 133 | client.username = _cfg.get("antd.connect", "username") 134 | client.password = _cfg.get("antd.connect", "password") 135 | client.cache = os.path.expanduser(_cfg.get("antd.connect", "cache")) 136 | return client 137 | except ConfigParser.NoSectionError: pass 138 | 139 | def create_gupload_plugin(): 140 | try: 141 | if _cfg.getboolean("antd.gupload", "enabled"): 142 | import antd.connect as connect 143 | client = connect.GUpload() 144 | client.username = _cfg.get("antd.gupload", "username") 145 | client.password = _cfg.get("antd.gupload", "password") 146 | client.cache = os.path.expanduser(_cfg.get("antd.gupload", "cache")) 147 | return client 148 | except ConfigParser.NoSectionError: pass 149 | 150 | def create_strava_plugin(): 151 | try: 152 | if _cfg.getboolean("antd.strava", "enabled"): 153 | import antd.connect as connect 154 | client = connect.StravaConnect() 155 | client.smtp_server = _cfg.get("antd.strava", "smtp_server") 156 | client.smtp_port = _cfg.get("antd.strava", "smtp_port") 157 | client.smtp_username = _cfg.get("antd.strava", "smtp_username") 158 | client.smtp_password = _cfg.get("antd.strava", "smtp_password") 159 | return client 160 | except ConfigParser.NoSectionError: pass 161 | 162 | def create_tcx_plugin(): 163 | if _cfg.getboolean("antd.tcx", "enabled"): 164 | import antd.tcx as tcx 165 | tcx = tcx.TcxPlugin() 166 | tcx.tcx_output_dir = os.path.expanduser(_cfg.get("antd.tcx", "tcx_output_dir")) 167 | try: 168 | tcx.cache = os.path.expanduser(_cfg.get("antd.tcx", "cache")) 169 | except ConfigParser.NoOptionError: pass 170 | return tcx 171 | 172 | def create_notification_plugin(): 173 | try: 174 | if _cfg.getboolean("antd.notification", "enabled"): 175 | import antd.notif as notif 176 | notif = notif.NotifPlugin() 177 | return notif 178 | except ConfigParser.NoSectionError: pass 179 | 180 | def get_path(section, key, file="", tokens={}): 181 | path = os.path.expanduser(_cfg.get(section, key)) 182 | path = path % tokens 183 | if not os.path.exists(path): os.makedirs(path) 184 | return os.path.sep.join([path, file]) if file else path 185 | 186 | def get_delete_from_device(): 187 | try: 188 | return _cfg.getboolean("antd", "delete_from_device") 189 | except ConfigParser.NoOptionError: 190 | return False 191 | 192 | def get_retry(): 193 | return int(_cfg.get("antd", "retry"), 0) 194 | 195 | def get_raw_output_dir(): 196 | return get_path("antd", "raw_output_dir") 197 | 198 | 199 | # vim: ts=4 sts=4 et 200 | -------------------------------------------------------------------------------- /antd/connect.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012, Braiden Kindt. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions 6 | # are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # 2. Redistributions in binary form must reproduce the above 12 | # copyright notice, this list of conditions and the following 13 | # disclaimer in the documentation and/or other materials 14 | # provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 17 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 26 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | # POSSIBILITY OF SUCH DAMAGE. 28 | 29 | 30 | import logging 31 | import os 32 | import sys 33 | import requests 34 | import json 35 | import glob 36 | import time 37 | import re 38 | import tempfile 39 | import subprocess 40 | 41 | import antd.plugin as plugin 42 | 43 | _log = logging.getLogger("antd.connect") 44 | 45 | class GarminConnect(plugin.Plugin): 46 | 47 | username = None 48 | password = None 49 | 50 | logged_in = False 51 | login_invalid = False 52 | 53 | rsession = None 54 | 55 | def __init__(self): 56 | rate_lock_path = tempfile.gettempdir() + "/gc_rate.%s.lock" % "0.0.0.0" 57 | # Ensure the rate lock file exists (...the easy way) 58 | open(rate_lock_path, "a").close() 59 | self._rate_lock = open(rate_lock_path, "r+") 60 | return 61 | 62 | # Borrowed to support new Garmin login 63 | # https://github.com/cpfair/tapiriik 64 | def _rate_limit(self): 65 | import fcntl, struct, time 66 | print("Waiting for lock") 67 | min_period = 1 # I appear to been banned from Garmin Connect while determining this. 68 | fcntl.flock(self._rate_lock,fcntl.LOCK_EX) 69 | try: 70 | self._rate_lock.seek(0) 71 | last_req_start = self._rate_lock.read() 72 | if not last_req_start: 73 | last_req_start = 0 74 | else: 75 | last_req_start = float(last_req_start) 76 | 77 | wait_time = max(0, min_period - (time.time() - last_req_start)) 78 | time.sleep(wait_time) 79 | 80 | self._rate_lock.seek(0) 81 | self._rate_lock.write(str(time.time())) 82 | self._rate_lock.flush() 83 | finally: 84 | fcntl.flock(self._rate_lock,fcntl.LOCK_UN) 85 | 86 | # work around old versions of requests 87 | def get_response_text(self, response): 88 | return response.text if hasattr(response, "text") else response.content 89 | 90 | def data_available(self, device_sn, format, files): 91 | if format not in ("tcx"): return files 92 | result = [] 93 | try: 94 | for file in files: 95 | self.login() 96 | self.upload(format, file) 97 | result.append(file) 98 | plugin.publish_data(device_sn, "notif_connect", files) 99 | except Exception: 100 | _log.warning("Failed to upload to Garmin Connect.", exc_info=True) 101 | finally: 102 | return result 103 | 104 | def login(self): 105 | if self.logged_in: return 106 | if self.login_invalid: raise InvalidLogin() 107 | 108 | # Use a session, removes the need to manage cookies ourselves 109 | self.rsession = requests.Session() 110 | 111 | _log.debug("Checking to see what style of login to use for Garmin Connect.") 112 | #Login code taken almost directly from https://github.com/cpfair/tapiriik/ 113 | self._rate_limit() 114 | gcPreResp = self.rsession.get("http://connect.garmin.com/", allow_redirects=False) 115 | # New site gets this redirect, old one does not 116 | if gcPreResp.status_code == 200: 117 | _log.debug("Using old login style") 118 | params = {"login": "login", "login:loginUsernameField": self.username, "login:password": self.password, "login:signInButton": "Sign In", "javax.faces.ViewState": "j_id1"} 119 | auth_retries = 3 # Did I mention Garmin Connect is silly? 120 | for retries in range(auth_retries): 121 | self._rate_limit() 122 | resp = self.rsession.post("https://connect.garmin.com/signin", data=params, allow_redirects=False, cookies=gcPreResp.cookies) 123 | if resp.status_code >= 500 and resp.status_code < 600: 124 | raise APIException("Remote API failure") 125 | if resp.status_code != 302: # yep 126 | if "errorMessage" in self.get_response_text(resp): 127 | if retries < auth_retries - 1: 128 | time.sleep(1) 129 | continue 130 | else: 131 | login_invalid = True 132 | raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) 133 | else: 134 | raise APIException("Mystery login error %s" % self.get_response_text(resp)) 135 | _log.debug("Old style login complete") 136 | break 137 | elif gcPreResp.status_code == 302: 138 | _log.debug("Using new style login") 139 | # JSIG CAS, cool I guess. 140 | # Not quite OAuth though, so I'll continue to collect raw credentials. 141 | # Commented stuff left in case this ever breaks because of missing parameters... 142 | data = { 143 | "username": self.username, 144 | "password": self.password, 145 | "_eventId": "submit", 146 | "embed": "true", 147 | # "displayNameRequired": "false" 148 | } 149 | params = { 150 | "service": "http://connect.garmin.com/post-auth/login", 151 | # "redirectAfterAccountLoginUrl": "http://connect.garmin.com/post-auth/login", 152 | # "redirectAfterAccountCreationUrl": "http://connect.garmin.com/post-auth/login", 153 | # "webhost": "olaxpw-connect00.garmin.com", 154 | "clientId": "GarminConnect", 155 | # "gauthHost": "https://sso.garmin.com/sso", 156 | # "rememberMeShown": "true", 157 | # "rememberMeChecked": "false", 158 | "consumeServiceTicket": "false", 159 | # "id": "gauth-widget", 160 | # "embedWidget": "false", 161 | # "cssUrl": "https://static.garmincdn.com/com.garmin.connect/ui/src-css/gauth-custom.css", 162 | # "source": "http://connect.garmin.com/en-US/signin", 163 | # "createAccountShown": "true", 164 | # "openCreateAccount": "false", 165 | # "usernameShown": "true", 166 | # "displayNameShown": "false", 167 | # "initialFocus": "true", 168 | # "locale": "en" 169 | } 170 | _log.debug("Fetching login variables") 171 | 172 | # I may never understand what motivates people to mangle a perfectly good protocol like HTTP in the ways they do... 173 | preResp = self.rsession.get("https://sso.garmin.com/sso/login", params=params) 174 | if preResp.status_code != 200: 175 | raise APIException("SSO prestart error %s %s" % (preResp.status_code, self.get_response_text(preResp))) 176 | data["lt"] = re.search("name=\"lt\"\s+value=\"([^\"]+)\"", self.get_response_text(preResp)).groups(1)[0] 177 | _log.debug("lt=%s"%data["lt"]) 178 | 179 | _log.debug("Posting login credentials to Garmin Connect. username=%s", self.username) 180 | ssoResp = self.rsession.post("https://sso.garmin.com/sso/login", params=params, data=data, allow_redirects=False) 181 | if ssoResp.status_code != 200: 182 | login_invalid = True 183 | _log.error("Login failed") 184 | raise APIException("SSO error %s %s" % (ssoResp.status_code, self.get_response_text(ssoResp))) 185 | 186 | ticket_match = re.search("ticket=([^']+)'", self.get_response_text(ssoResp)) 187 | if not ticket_match: 188 | login_invalid = True 189 | raise APIException("Invalid login", block=True, user_exception=UserException(UserExceptionType.Authorization, intervention_required=True)) 190 | ticket = ticket_match.groups(1)[0] 191 | 192 | # ...AND WE'RE NOT DONE YET! 193 | 194 | self._rate_limit() 195 | gcRedeemResp = self.rsession.get("https://connect.garmin.com/post-auth/login", params={"ticket": ticket}, allow_redirects=False) 196 | if gcRedeemResp.status_code != 302: 197 | raise APIException("GC redeem-start error %s %s" % (gcRedeemResp.status_code, gcRedeemResp.text)) 198 | 199 | # There are 6 redirects that need to be followed to get the correct cookie 200 | # ... :( 201 | expected_redirect_count = 6 202 | current_redirect_count = 1 203 | while True: 204 | self._rate_limit() 205 | gcRedeemResp = self.rsession.get(gcRedeemResp.headers["location"], allow_redirects=False) 206 | 207 | if current_redirect_count >= expected_redirect_count and gcRedeemResp.status_code != 200: 208 | raise APIException("GC redeem %d/%d error %s %s" % (current_redirect_count, expected_redirect_count, gcRedeemResp.status_code, gcRedeemResp.text)) 209 | if gcRedeemResp.status_code == 200 or gcRedeemResp.status_code == 404: 210 | break 211 | current_redirect_count += 1 212 | if current_redirect_count > expected_redirect_count: 213 | break 214 | 215 | else: 216 | raise APIException("Unknown GC prestart response %s %s" % (gcPreResp.status_code, self.get_response_text(gcPreResp))) 217 | 218 | 219 | self.logged_in = True 220 | 221 | 222 | def upload(self, format, file_name): 223 | #TODO: Restore streaming for upload 224 | with open(file_name) as file: 225 | files = {'file': file} 226 | _log.info("Uploading %s to Garmin Connect.", file_name) 227 | r = self.rsession.post("https://connect.garmin.com/proxy/upload-service-1.1/json/upload/.%s" % format, files=files) 228 | 229 | class StravaConnect(plugin.Plugin): 230 | 231 | server = None 232 | smtp_server = None 233 | smtp_port = None 234 | smtp_username = None 235 | smtp_password = None 236 | 237 | logged_in = False 238 | 239 | def __init__(self): 240 | from smtplib import SMTP 241 | self.server = SMTP() 242 | pass 243 | 244 | def data_available(self, device_sn, format, files): 245 | if format not in ("tcx"): return files 246 | result = [] 247 | try: 248 | for file in files: 249 | self.login() 250 | self.upload(format, file) 251 | result.append(file) 252 | self.logout() 253 | except Exception: 254 | _log.warning("Failed to upload to Strava.", exc_info=True) 255 | finally: 256 | return result 257 | 258 | def logout(self): 259 | self.server.close() 260 | 261 | def login(self): 262 | if self.logged_in: return 263 | self.server.connect(self.smtp_server, self.smtp_port) 264 | self.server.ehlo() 265 | self.server.starttls() 266 | self.server.ehlo() 267 | self.server.login(self.smtp_username, self.smtp_password) 268 | self.logged_in = True 269 | 270 | def upload(self, format, file_name): 271 | from email.mime.base import MIMEBase 272 | from email.mime.multipart import MIMEMultipart 273 | import datetime 274 | from email import encoders 275 | outer = MIMEMultipart() 276 | outer['Subject'] = 'Garmin Data Upload from %s' % datetime.date.today() 277 | outer['To' ] = 'upload@strava.com' 278 | outer['From' ] = self.smtp_username 279 | outer.preamble = 'You will not see this in a MIME-aware mail reader.\n' 280 | with open(file_name, 'rb') as fp: 281 | msg = MIMEBase('application', 'octet-stream') 282 | msg.set_payload(fp.read()) 283 | encoders.encode_base64(msg) 284 | msg.add_header('Content-Disposition', 'attachment', filename=file_name) 285 | outer.attach(msg) 286 | self.server.sendmail(self.smtp_username, 'upload@strava.com', outer.as_string()) 287 | 288 | class GUpload(plugin.Plugin): 289 | username = None 290 | password = None 291 | 292 | def data_available(self, device_sn, format, files): 293 | if format not in ("tcx"): return files 294 | 295 | # gupload exits with an error code !=0 if it fails. This in turn makes subprocess.check_output 296 | # raise an exception, and we return that no files were processed 297 | command = 'gupload -v 1 -u %s -p %s ' % (self.username, self.password) + " ".join(files) 298 | 299 | try: 300 | _log.debug("Executing '%s'" % command) 301 | executed = subprocess.check_output(command, shell=True) 302 | _log.debug(executed) 303 | return files 304 | except Exception: 305 | _log.warning("Failed to upload using gupload.", exc_info=True) 306 | 307 | return [] 308 | 309 | class InvalidLogin(Exception): pass 310 | 311 | # vim: ts=4 sts=4 et 312 | -------------------------------------------------------------------------------- /antd/antfs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012, Braiden Kindt. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions 6 | # are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # 11 | # 2. Redistributions in binary form must reproduce the above 12 | # copyright notice, this list of conditions and the following 13 | # disclaimer in the documentation and/or other materials 14 | # provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS 17 | # ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | # LIABILITY, OR TORT(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY 26 | # WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | # POSSIBILITY OF SUCH DAMAGE. 28 | 29 | 30 | import random 31 | import struct 32 | import collections 33 | import logging 34 | import time 35 | import os 36 | import socket 37 | import binascii 38 | import ConfigParser 39 | 40 | import antd.ant as ant 41 | 42 | _log = logging.getLogger("antd.antfs") 43 | 44 | ANTFS_HOST_ID = os.getpid() & 0xFFFFFFFF 45 | ANTFS_HOST_NAME = socket.gethostname()[:8] 46 | 47 | 48 | class Beacon(object): 49 | 50 | DATA_PAGE_ID = 0x43 51 | STATE_LINK, STATE_AUTH, STATE_TRANSPORT, STATE_BUSY = range(0,4) 52 | 53 | __struct = struct.Struct(" max_len: return str[:max_len] + "..." 244 | else: return str 245 | 246 | def extract_wpts(protocols, get_trks_pkts, index): 247 | """ 248 | Given a collection of track points packets, 249 | return those which are members of given track index. 250 | Where PID_TRK_DATA_ARRAY is encountered, data is expanded 251 | such that result is equvalent to cas where each was its 252 | on packet of PID_TRK_DATA 253 | """ 254 | i = iter(get_trks_pkts) 255 | # position iter at first wpt record of given index 256 | for pid, length, data in i: 257 | if pid == protocols.link_proto.PID_TRK_HDR and data.index == index: 258 | break 259 | # extract wpts 260 | for pkt in i: 261 | if pkt.pid == protocols.link_proto.PID_TRK_HDR: 262 | break 263 | elif pkt.pid == protocols.link_proto.PID_TRK_DATA: 264 | yield data 265 | elif pkt.pid == protocols.link_proto.PID_TRK_DATA_ARRAY: 266 | for wpt in pkt.data.wpts: yield wpt 267 | 268 | def extract_runs(protocols, get_runs_pkts): 269 | """ 270 | Given garmin packets which are result of A1000 (get_runs) 271 | Return an object tree runs->laps->points for easier processing. 272 | """ 273 | runs, laps, trks = get_runs_pkts 274 | runs = [r.data for r in runs.by_pid[protocols.link_proto.PID_RUN]] 275 | laps = [l.data for l in laps.by_pid[protocols.link_proto.PID_LAP]] 276 | _log.debug("extract_runs: found %d run(s)", len(runs)) 277 | for run_num, run in enumerate(runs): 278 | run.laps = [l for l in laps if run.first_lap_index <= l.index <= run.last_lap_index] 279 | run.time.time = run.laps[0].start_time.time 280 | run.wpts = list(extract_wpts(protocols, trks, run.track_index)) 281 | _log.debug("extract_runs: run %d has: %d lap(s), %d wpt(s)", run_num + 1, len(run.laps), len(run.wpts)) 282 | for lap in run.laps: lap.wpts = [] 283 | lap_num = 0 284 | for wpt in run.wpts: 285 | try: 286 | while wpt.time.time >= run.laps[lap_num + 1].start_time.time: 287 | _log.debug("extract_runs: run %d lap %d has: %d wpt(s)", 288 | run_num + 1, lap_num + 1, len(run.laps[lap_num].wpts)) 289 | lap_num += 1 290 | except IndexError: 291 | pass 292 | run.laps[lap_num].wpts.append(wpt) 293 | all_wpt_in_laps = sum(len(lap.wpts) for lap in run.laps) 294 | if len(run.wpts) != all_wpt_in_laps: 295 | _log.warning("extract_runs: run %d waypoint mismatch: total(%d) != wpt_in_laps(%d)", 296 | run_num + 1, len(run.wpts), all_wpt_in_laps) 297 | return runs 298 | 299 | 300 | class Device(object): 301 | """ 302 | Class represents a garmin gps device. 303 | Methods of this class will delegate to 304 | the specific protocols impelemnted by this 305 | device. They may raise DeviceNotSupportedError 306 | if the device does not implement a specific 307 | operation. 308 | """ 309 | 310 | def __init__(self, stream): 311 | self.stream = stream 312 | self.init_device_api() 313 | 314 | def get_product_data(self): 315 | """ 316 | Get product capabilities. 317 | """ 318 | return self.execute(A000())[0] 319 | 320 | def get_runs(self): 321 | """ 322 | Get new runs from device. 323 | """ 324 | if self.run_proto: 325 | return self.execute(self.run_proto) 326 | else: 327 | raise DeviceNotSupportedError("Device does not support get_runs.") 328 | 329 | def delete_runs(self): 330 | """ 331 | Delete runs from device. 332 | UNDOCUMENTED, implementation does not delegate to protocol array. 333 | This method won't raise error on unsupported hardware, and my silently fail. 334 | """ 335 | return self.execute(DeleteRuns(self)) 336 | 337 | def init_device_api(self): 338 | """ 339 | Initialize the protocols used by this 340 | instance based on the protocol capabilities 341 | array which is return from A000. 342 | """ 343 | product_data = self.get_product_data() 344 | try: 345 | self.device_id = product_data.by_pid[L000.PID_PRODUCT_DATA][0].data 346 | self.protocol_array = product_data.by_pid[L000.PID_PROTOCOL_ARRAY][0].data.protocol_array 347 | _log.debug("init_device_api: product_id=%d, software_version=%0.2f, description=%s", 348 | self.device_id.product_id, self.device_id.software_version/100., self.device_id.description) 349 | _log.debug("init_device_api: protocol_array=%s", self.protocol_array) 350 | except (IndexError, TypeError): 351 | raise DeviceNotSupportedError("Product data not returned by device.") 352 | self.data_types_by_protocol = data_types_by_protocol(self.protocol_array) 353 | # the tuples in this section define an ordered collection 354 | # of protocols which are candidates to provide each specific 355 | # function. Each proto will be device based on the first one 356 | # whihc exists in this devices capabiltities. 357 | # This section needs to be updated whenever a new protocol 358 | # needs to be supported. 359 | self.link_proto = self._find_core_protocol("link", (L000, L001)) 360 | self.cmd_proto = self._find_core_protocol("command", (A010,)) 361 | self.trk_proto = self._find_app_protocol("get_trks", (A301, A302)) 362 | self.lap_proto = self._find_app_protocol("get_laps", (A906,)) 363 | self.run_proto = self._find_app_protocol("get_runs", (A1000,)) 364 | 365 | def _find_core_protocol(self, name, candidates): 366 | """ 367 | Return the first procotol in candidates 368 | which is supported by this device. 369 | """ 370 | proto = get_proto_cls(self.protocol_array, candidates) 371 | if proto: 372 | _log.debug("Using %s protocol %s.", name, proto.__name__) 373 | else: 374 | raise DeviceNotSupportedError("Device does not implement a known link protocol. capabilities=%s" 375 | % self.protocol_array) 376 | return proto() 377 | 378 | def _find_app_protocol(self, function_name, candidates): 379 | """ 380 | Return the first protocol in candidates whihc 381 | is supported by this device. additionally, check 382 | that the datatypes which are returned by the give 383 | protocol are implented by this python module. 384 | If not a warning is logged. (but no excetpion is raised._ 385 | This allows raw data dump to succeed, but trx generation to fail. 386 | """ 387 | cls = get_proto_cls(self.protocol_array, candidates) 388 | data_types = self.data_types_by_protocol.get(cls.__name__, []) 389 | data_type_cls = [globals().get(nm, DataType) for nm in data_types] 390 | if not cls: 391 | _log.warning("Download may FAIL. Protocol unimplemented. %s:%s", function_name, candidates) 392 | else: 393 | _log.debug("Using %s%s for: %s", cls.__name__, data_types, function_name) 394 | if DataType in data_type_cls: 395 | _log.warning("Download may FAIL. DataType unimplemented. %s:%s%s", function_name, cls.__name__, data_types) 396 | try: 397 | return cls(self, *data_type_cls) 398 | except Exception: 399 | _log.warning("Download may Fail. Failed to ceate protocol %s.", function_name, exc_info=True) 400 | 401 | def execute(self, protocol): 402 | """ 403 | Execute the give garmin Applection protcol. 404 | e.g. one of the Annn classes. 405 | """ 406 | result = [] 407 | for next in protocol.execute(): 408 | if hasattr(next, "execute"): 409 | result.extend(self.execute(next)) 410 | else: 411 | pid, data = next 412 | in_packets = [] 413 | self.stream.write(pack(pid, data)) 414 | while True: 415 | pkt = self.stream.read() 416 | if not pkt: break 417 | for pid, length, data in tokenize(pkt): 418 | in_packets.append((pid, length, protocol.decode_packet(pid, length, data))) 419 | self.stream.write(pack(P000.PID_ACK, pid)) 420 | in_packets.append((0, 0, None)) 421 | result.append(protocol.decode_list(in_packets)) 422 | 423 | return protocol.decode_result(result) 424 | 425 | 426 | class MockHost(object): 427 | """ 428 | A mock device which can be used 429 | when instantiating a Device. 430 | Rather than accessing hardware, 431 | commands are replayed though given 432 | string (which can be read from file. 433 | This class is dumb, so caller has 434 | to ensure pkts in the import string 435 | or file are properly ordered. 436 | """ 437 | 438 | def __init__(self, data): 439 | self.reader = self._read(data) 440 | 441 | def write(self, *args, **kwds): 442 | pass 443 | 444 | def read(self): 445 | try: 446 | return self.reader.next() 447 | except StopIteration: 448 | return "" 449 | 450 | def _read(self, data): 451 | while data: 452 | (length,) = struct.unpack("", "!"): 276 | # apply default by order and size 277 | byte_order_and_size = "<" 278 | msg_struct = struct.Struct(byte_order_and_size + pack_format) 279 | else: 280 | msg_struct = None 281 | 282 | # create named-tuple used to converting *arg, **kwds to this messages args 283 | msg_arg_tuple = collections.namedtuple(name, arg_names) 284 | 285 | # class representing the message definition pased to this method 286 | class Message(object): 287 | 288 | DIRECTION = direction 289 | NAME = name 290 | ID = id 291 | 292 | def __init__(self, *args, **kwds): 293 | tuple = msg_arg_tuple(*args, **kwds) 294 | self.__dict__.update(tuple._asdict()) 295 | 296 | @property 297 | def args(self): 298 | return msg_arg_tuple(**dict((k, v) for k, v in self.__dict__.items() if k in arg_names)) 299 | 300 | @classmethod 301 | def unpack_args(cls, packed_args): 302 | try: return Message(*msg_struct.unpack(packed_args)) 303 | except AttributeError: return Message(*([None] * len(arg_names))) 304 | 305 | def pack_args(self): 306 | try: return msg_struct.pack(*self.args) 307 | except AttributeError: pass 308 | 309 | def pack_size(self): 310 | try: return msg_struct.size 311 | except AttributeError: return 0 312 | 313 | def is_retryable(self, err): 314 | return retry_policy(err) 315 | 316 | def is_reply(self, cmd): 317 | return matcher(self, cmd) 318 | 319 | def validate_reply(self, cmd): 320 | return validator(self, cmd) 321 | 322 | def __str__(self): 323 | return str(self.args) 324 | 325 | return Message 326 | 327 | # ANT Message Protocol Definitions 328 | UnassignChannel = message(DIR_OUT, "UNASSIGN_CHANNEL", 0x41, "B", ["channel_number"], retry_policy=timeout_retry_policy) 329 | AssignChannel = message(DIR_OUT, "ASSIGN_CHANNEL", 0x42, "BBB", ["channel_number", "channel_type", "network_number"], retry_policy=timeout_retry_policy) 330 | SetChannelId = message(DIR_OUT, "SET_CHANNEL_ID", 0x51, "BHBB", ["channel_number", "device_number", "device_type_id", "trans_type"], retry_policy=timeout_retry_policy) 331 | SetChannelPeriod = message(DIR_OUT, "SET_CHANNEL_PERIOD", 0x43, "BH", ["channel_number", "messaging_period"], retry_policy=timeout_retry_policy) 332 | SetChannelSearchTimeout = message(DIR_OUT, "SET_CHANNEL_SEARCH_TIMEOUT", 0x44, "BB", ["channel_number", "search_timeout"], retry_policy=timeout_retry_policy) 333 | SetChannelRfFreq = message(DIR_OUT, "SET_CHANNEL_RF_FREQ", 0x45, "BB", ["channel_number", "rf_freq"], retry_policy=timeout_retry_policy) 334 | SetNetworkKey = message(DIR_OUT, "SET_NETWORK_KEY", 0x46, "B8s", ["network_number", "network_key"], retry_policy=timeout_retry_policy) 335 | ResetSystem = message(DIR_OUT, "RESET_SYSTEM", 0x4a, "x", [], retry_policy=always_retry_policy, matcher=reset_matcher) 336 | OpenChannel = message(DIR_OUT, "OPEN_CHANNEL", 0x4b, "B", ["channel_number"], retry_policy=timeout_retry_policy) 337 | CloseChannel = message(DIR_OUT, "CLOSE_CHANNEL", 0x4c, "B", ["channel_number"], retry_policy=timeout_retry_policy, matcher=close_channel_matcher, validator=close_channel_validator) 338 | RequestMessage = message(DIR_OUT, "REQUEST_MESSAGE", 0x4d, "BB", ["channel_number", "msg_id"], retry_policy=timeout_retry_policy, matcher=request_message_matcher) 339 | SetSearchWaveform = message(DIR_OUT, "SET_SEARCH_WAVEFORM", 0x49, "BH", ["channel_number", "waveform"], retry_policy=timeout_retry_policy) 340 | SendBroadcastData = message(DIR_OUT, "SEND_BROADCAST_DATA", 0x4e, "B8s", ["channel_number", "data"], matcher=send_data_matcher, validator=send_data_validator) 341 | SendAcknowledgedData = message(DIR_OUT, "SEND_ACKNOWLEDGED_DATA", 0x4f, "B8s", ["channel_number", "data"], retry_policy=wait_and_retry_policy, matcher=send_data_matcher, validator=send_data_validator) 342 | SendBurstTransferPacket = message(DIR_OUT, "SEND_BURST_TRANSFER_PACKET", 0x50, "B8s", ["channel_number", "data"], retry_policy=wait_and_retry_policy, matcher=send_data_matcher, validator=send_data_validator) 343 | StartupMessage = message(DIR_IN, "STARTUP_MESSAGE", 0x6f, "B", ["startup_message"]) 344 | SerialError = message(DIR_IN, "SERIAL_ERROR", 0xae, None, ["error_number", "msg_contents"]) 345 | RecvBroadcastData = message(DIR_IN, "RECV_BROADCAST_DATA", 0x4e, "B8s", ["channel_number", "data"]) 346 | RecvAcknowledgedData = message(DIR_IN, "RECV_ACKNOWLEDGED_DATA", 0x4f, "B8s", ["channel_number", "data"]) 347 | RecvBurstTransferPacket = message(DIR_IN, "RECV_BURST_TRANSFER_PACKET", 0x50, "B8s", ["channel_number", "data"]) 348 | ChannelEvent = message(DIR_IN, "CHANNEL_EVENT", 0x40, "BBB", ["channel_number", "msg_id", "msg_code"]) 349 | ChannelStatus = message(DIR_IN, "CHANNEL_STATUS", 0x52, "BB", ["channel_number", "channel_status"]) 350 | ChannelId = message(DIR_IN, "CHANNEL_ID", 0x51, "BHBB", ["channel_number", "device_number", "device_type_id", "man_id"]) 351 | AntVersion = message(DIR_IN, "VERSION", 0x3e, "11s", ["ant_version"]) 352 | #Capabilities = message(DIR_IN, "CAPABILITIES", 0x54, "BBBBBx", ["max_channels", "max_networks", "standard_opts", "advanced_opts1", "advanced_opts2"]) 353 | SerialNumber = message(DIR_IN, "SERIAL_NUMBER", 0x61, "I", ["serial_number"]) 354 | # Synthetic Commands 355 | UnimplementedCommand = message(None, "UNIMPLEMENTED_COMMAND", None, None, ["msg_id", "msg_contents"]) 356 | 357 | # hack, capabilities may be 4 (AP1) or 6 (AP2) bytes 358 | class Capabilities(message(DIR_IN, "CAPABILITIES", 0x54, "BBBB", ["max_channels", "max_networks", "standard_opts", "advanced_opts1"])): 359 | 360 | @classmethod 361 | def unpack_args(cls, packed_args): 362 | return super(Capabilities, cls).unpack_args(packed_args[:4]) 363 | 364 | 365 | ALL_ANT_COMMANDS = [ UnassignChannel, AssignChannel, SetChannelId, SetChannelPeriod, SetChannelSearchTimeout, 366 | SetChannelRfFreq, SetNetworkKey, ResetSystem, OpenChannel, CloseChannel, RequestMessage, 367 | SetSearchWaveform, SendBroadcastData, SendAcknowledgedData, SendBurstTransferPacket, 368 | StartupMessage, SerialError, RecvBroadcastData, RecvAcknowledgedData, RecvBurstTransferPacket, 369 | ChannelEvent, ChannelStatus, ChannelId, AntVersion, Capabilities, SerialNumber ] 370 | 371 | class ReadData(RequestMessage): 372 | """ 373 | A phony command which is pushed to request data from client. 374 | This command will remain running as long as the channel is 375 | in a state where read is valid, and raise error if channel 376 | transitions to a state where read is impossible. Its kind-of 377 | an ugly hack so that channel status causes exceptions in read. 378 | """ 379 | 380 | def __init__(self, channel_id, data_type): 381 | super(ReadData, self).__init__(channel_id, ChannelStatus.ID) 382 | self.data_type = data_type 383 | 384 | def is_retryable(self): 385 | return False 386 | 387 | def is_reply(self, cmd): 388 | return ((same_channel_or_network_matcher(self, cmd) 389 | and isinstance(cmd, ChannelStatus) 390 | and cmd.channel_status & 0x03 not in (CHANNEL_STATUS_SEARCHING, CHANNEL_STATUS_TRACKING)) 391 | or close_channel_matcher(self, cmd)) 392 | 393 | def validate_reply(self, cmd): 394 | return AntChannelClosedError("Channel closed. %s" % cmd) 395 | 396 | def __str__(self): 397 | return "ReadData(channel_number=%d)" % self.channel_number 398 | 399 | class SendBurstData(SendBurstTransferPacket): 400 | 401 | data = None 402 | channel_number = None 403 | 404 | def __init__(self, channel_number, data): 405 | if len(data) <= 8: channel_number |= 0x80 406 | super(SendBurstData, self).__init__(channel_number, data) 407 | 408 | @property 409 | def done(self): 410 | return self._done 411 | 412 | @done.setter 413 | def done(self, value): 414 | self._done = value 415 | self.seq_num = 0 416 | self.index = 0 417 | self.incr_packet_index() 418 | 419 | def create_next_packet(self): 420 | """ 421 | Return a command which can be exceuted 422 | to deliver the next packet of this burst. 423 | """ 424 | is_last_packet = self.index + 8 >= len(self.data) 425 | data = self.data[self.index:self.index + 8] 426 | channel_number = self.channel_number | ((self.seq_num & 0x03) << 5) | (0x80 if is_last_packet else 0x00) 427 | return SendBurstTransferPacket(channel_number, data) 428 | 429 | def incr_packet_index(self): 430 | """ 431 | Increment the pointer for data in next packet. 432 | create_next_packet() will update index until 433 | this method is called. 434 | """ 435 | self.seq_num += 1 436 | if not self.seq_num & 0x03: self.seq_num += 1 437 | self.index += 8 438 | self.has_more_data = self.index < len(self.data) 439 | 440 | def __str__(self): 441 | return "SEND_BURST_COMMAND(channel_number=%d)" % self.channel_number 442 | 443 | 444 | class Core(object): 445 | """ 446 | Asynchronous ANT api. 447 | """ 448 | 449 | def __init__(self, hardware, messages=ALL_ANT_COMMANDS): 450 | self.hardware = hardware 451 | self.input_msg_by_id = dict((m.ID, m) for m in messages if m.DIRECTION == DIR_IN) 452 | # per ant protocol doc, writing 15 zeros 453 | # should reset internal state of device. 454 | #self.hardware.write([0] * 15, 100) 455 | 456 | def close(self): 457 | self.hardware.close() 458 | 459 | def pack(self, command): 460 | """ 461 | Return an array of bytes representing 462 | the data which needs to be written to 463 | hardware to execute the given command. 464 | """ 465 | if command.ID is not None: 466 | if command.DIRECTION != DIR_OUT: 467 | _log.warning("Request to pack input message. %s", command) 468 | msg = [SYNC, command.pack_size(), command.ID] 469 | msg_args = command.pack_args() 470 | if msg_args is not None: 471 | msg.extend(array.array("B", msg_args)) 472 | msg.append(generate_checksum(msg)) 473 | return msg 474 | 475 | def unpack(self, msg): 476 | """ 477 | Return the command represented by 478 | the given byte ANT array. 479 | """ 480 | if not validate_checksum(msg): 481 | _log.error("Invalid checksum, mesage discarded. %s", msg_to_string(msg)) 482 | return None 483 | sync, length, msg_id = msg[:3] 484 | try: 485 | command_class = self.input_msg_by_id[msg_id] 486 | except (KeyError): 487 | _log.warning("Attempt to unpack unkown message (0x%02x). %s", msg_id, msg_to_string(msg)) 488 | return UnimplementedCommand(msg_id, msg) 489 | else: 490 | return command_class.unpack_args(array.array("B", msg[3:-1]).tostring()) 491 | 492 | def send(self, command, timeout=100): 493 | """ 494 | Execute the given command. Returns true 495 | if command was written to device. False 496 | if the device nack'd the write. When 497 | the method returns false, caller should 498 | retry. 499 | """ 500 | msg = self.pack(command) 501 | if not msg: return True 502 | _trace.debug("SEND: %s", msg_to_string(msg)) 503 | # ant protocol states \x00\x00 padding is optional. 504 | # libusb01 is quirky when using multiple threads? 505 | # adding the \00's seems to help with occasional issue 506 | # where read can block indefinitely until more data 507 | # is received. 508 | msg.extend([0] * 2) 509 | try: 510 | self.hardware.write(msg, timeout) 511 | return True 512 | except IOError as err: 513 | if is_timeout(err): return False 514 | else: raise 515 | 516 | def recv(self, timeout=1000): 517 | """ 518 | A generator which return commands 519 | parsed from input stream of ant device. 520 | StopIteration raised when input stream empty. 521 | """ 522 | while True: 523 | try: 524 | # tokenize message (possibly more than on per read) 525 | for msg in tokenize_message(self.hardware.read(timeout)): 526 | _trace.debug("RECV: %s", msg_to_string(msg)) 527 | cmd = self.unpack(msg) 528 | if cmd: yield cmd 529 | except IOError as err: 530 | # iteration terminates on timeout 531 | if is_timeout(err): raise StopIteration() 532 | else: raise 533 | 534 | 535 | class Session(object): 536 | """ 537 | Provides synchronous (blocking) API 538 | on top of basic (Core) ANT impl. 539 | """ 540 | 541 | default_read_timeout = 5 542 | default_write_timeout = 5 543 | default_retry = 9 544 | 545 | channels = [] 546 | networks = [] 547 | _recv_buffer = [] 548 | _burst_buffer = [] 549 | 550 | def __init__(self, core): 551 | self.core = core 552 | self.running = False 553 | self.running_cmd = None 554 | try: 555 | self._start() 556 | except Exception as e: 557 | try: self.close() 558 | except Exception: _log.warning("Caught exception trying to cleanup resources.", exc_info=True) 559 | finally: raise e 560 | 561 | def _start(self): 562 | """ 563 | Start the message consumer thread. 564 | """ 565 | if not self.running: 566 | self.running = True 567 | self.thread = threading.Thread(target=self.loop) 568 | self.thread.daemon = True 569 | self.thread.start() 570 | self.reset_system() 571 | 572 | def close(self): 573 | """ 574 | Stop the message consumer thread. 575 | """ 576 | try: 577 | self.reset_system() 578 | self.running = False 579 | self.thread.join(1) 580 | self.core.close() 581 | assert not self.thread.is_alive() 582 | except AttributeError: pass 583 | 584 | def reset_system(self): 585 | """ 586 | Reset the and device and initialize 587 | channel/network properties. 588 | """ 589 | self._send(ResetSystem(), timeout=.5, retry=5) 590 | if not self.channels: 591 | _log.debug("Querying ANT capabilities") 592 | cap = self.get_capabilities() 593 | #ver = self.get_ant_version() 594 | #sn = self.get_serial_number() 595 | _log.debug("Device Capabilities: %s", cap) 596 | #_log.debug("Device ANT Version: %s", ver) 597 | #_log.debug("Device SN#: %s", sn) 598 | self.channels = [Channel(self, n) for n in range(0, cap.max_channels)] 599 | self.networks = [Network(self, n) for n in range(0, cap.max_networks)] 600 | self._recv_buffer = [[]] * len(self.channels) 601 | self._burst_buffer = [[]] * len(self.channels) 602 | 603 | def get_capabilities(self): 604 | """ 605 | Return the capabilities of this device. 9.5.7.4 606 | """ 607 | return self._send(RequestMessage(0, Capabilities.ID)) 608 | 609 | def get_ant_version(self): 610 | """ 611 | Return the version on ANT firmware on device. 9.5.7.3 612 | """ 613 | return self._send(RequestMessage(0, AntVersion.ID)) 614 | 615 | def get_serial_number(self): 616 | """ 617 | Return SN# of and device. 9.5.7.5 618 | """ 619 | return self._send(RequestMessage(0, SerialNumber.ID)) 620 | 621 | def _send(self, cmd, timeout=1, retry=0): 622 | """ 623 | Execute the given command. An exception will 624 | be raised if reply is not received in timeout 625 | seconds. If retry is non-zero, commands returning 626 | EAGAIN will be retried. retry also appleis to 627 | RESET_SYSTEM commands. This method blocks until 628 | a response if received from hardware or timeout. 629 | Care should be taken to ensure timeout is sufficiently 630 | large. Care should be taken to ensure timeout is 631 | at least as large as a on message period. 632 | """ 633 | _log.debug("Executing Command. %s", cmd) 634 | for t in range(0, retry + 1): 635 | # invalid to send command while another is running 636 | # (except for reset system) 637 | assert not self.running_cmd or isinstance(cmd, ResetSystem) 638 | # HACK, need to clean this up. not all devices support sending 639 | # a response message for ResetSystem, so don't bother waiting for it 640 | if not isinstance(cmd, ResetSystem): 641 | # set expiration and event on command. Once self.running_cmd 642 | # is set access to this command from this thread is invalid 643 | # until event object is set. 644 | cmd.expiration = time.time() + timeout if timeout > 0 else None 645 | cmd.done = threading.Event() 646 | self.running_cmd = cmd 647 | else: 648 | # reset is done without waiting 649 | cmd.done = threading.Event() 650 | cmd.result = StartupMessage(0) 651 | # continue trying to commit command until session closed or command timeout 652 | while self.running and not cmd.done.is_set() and not self.core.send(cmd): 653 | _log.warning("Device write timeout. Will keep trying.") 654 | if isinstance(cmd, ResetSystem): 655 | # sleep to give time for reset to execute 656 | time.sleep(1) 657 | cmd.done.set() 658 | # continue waiting for command completion until session closed 659 | while self.running and not cmd.done.is_set(): 660 | if isinstance(cmd, SendBurstData) and cmd.has_more_data: 661 | # if the command being executed is burst 662 | # continue writing packets until data empty. 663 | # usb will nack packed it case where we're 664 | # overflowing the ant device. and packet will 665 | # be tring next time. 666 | packet = cmd.create_next_packet() 667 | if self.core.send(packet): cmd.incr_packet_index() 668 | else: 669 | cmd.done.wait(1) 670 | # cmd.done guarantees a result is available 671 | if cmd.done.is_set(): 672 | try: 673 | return cmd.result 674 | except AttributeError: 675 | # must have failed, check if error is retryable 676 | if t < retry and cmd.is_retryable(cmd.error): 677 | _log.warning("Retryable error. %d try(s) remaining. %s", retry - t, cmd.error) 678 | else: 679 | # not retryable, or too many retries 680 | raise cmd.error 681 | else: 682 | self.running_cmd = None 683 | raise AntError("Session closed.") 684 | 685 | def _handle_reply(self, cmd): 686 | """ 687 | Handle the given command, updating 688 | the status of running command if 689 | applicable. 690 | """ 691 | _log.debug("Processing reply. %s", cmd) 692 | if self.running_cmd and self.running_cmd.is_reply(cmd): 693 | err = self.running_cmd.validate_reply(cmd) 694 | if err: 695 | self._set_error(err) 696 | else: 697 | self._set_result(cmd) 698 | 699 | def _handle_timeout(self): 700 | """ 701 | Update the status of running command 702 | if the message has expired. 703 | """ 704 | # if a command is currently running, check for timeout condition 705 | if self.running_cmd and self.running_cmd.expiration and time.time() > self.running_cmd.expiration: 706 | self._set_error(AntTimeoutError("No reply to command. %s" % self.running_cmd)) 707 | 708 | def _handle_read(self, cmd=None): 709 | """ 710 | Append incoming ack messages to read buffer. 711 | Append completed burst message to buffer. 712 | Full run command from buffer if data available. 713 | """ 714 | # handle update the recv buffers 715 | try: 716 | # acknowledged data is immediately made avalible to client 717 | # (and buffered if no read is currently running) 718 | if isinstance(cmd, RecvAcknowledgedData): 719 | self._recv_buffer[cmd.channel_number].append(cmd) 720 | # burst data double-buffered. it is not made available to 721 | # client until the complete transfer is completed. 722 | elif isinstance(cmd, RecvBurstTransferPacket): 723 | channel_number = 0x1f & cmd.channel_number 724 | self._burst_buffer[channel_number].append(cmd) 725 | # burst complete, make the complete burst available for read. 726 | if cmd.channel_number & 0x80: 727 | _log.debug("Burst transfer completed, marking %d packets available for read.", len(self._burst_buffer[channel_number])) 728 | self._recv_buffer[channel_number].extend(self._burst_buffer[channel_number]) 729 | self._burst_buffer[channel_number] = [] 730 | # a burst transfer failed, any data currently read is discarded. 731 | # we assume the sender will retransmit the entire payload. 732 | elif isinstance(cmd, ChannelEvent) and cmd.msg_id == 1 and cmd.msg_code == EVENT_TRANSFER_RX_FAILED: 733 | _log.warning("Burst transfer failed, discarding data. %s", cmd) 734 | self._burst_buffer[cmd.channel_number] = [] 735 | except IndexError: 736 | _log.warning("Ignoring data, buffers not initialized. %s", cmd) 737 | 738 | # dispatcher data if running command is ReadData and something available 739 | if self.running_cmd and isinstance(self.running_cmd, ReadData): 740 | if isinstance(cmd, RecvBroadcastData) and self.running_cmd.data_type == RecvBroadcastData: 741 | # read broadcast is unbuffered, and blocks until a broadcast is received 742 | # if a broadcast is received and nobody is listening it is discarded. 743 | self._set_result(cmd) 744 | elif self._recv_buffer[self.running_cmd.channel_number]: 745 | if self.running_cmd.data_type == RecvAcknowledgedData: 746 | # return the most recent acknowledged data packet if one exists 747 | for ack_msg in [msg for msg in self._recv_buffer[self.running_cmd.channel_number] if isinstance(msg, RecvAcknowledgedData)]: 748 | self._set_result(ack_msg) 749 | self._recv_buffer[self.running_cmd.channel_number].remove(ack_msg) 750 | break 751 | elif self.running_cmd.data_type in (RecvBurstTransferPacket, ReadData): 752 | # select in a single entire burst transfer or ACK 753 | data = [] 754 | for pkt in list(self._recv_buffer[self.running_cmd.channel_number]): 755 | if isinstance(pkt, RecvBurstTransferPacket) or self.running_cmd.data_type == ReadData: 756 | data.append(pkt) 757 | self._recv_buffer[self.running_cmd.channel_number].remove(pkt) 758 | if pkt.channel_number & 0x80 or isinstance(pkt, RecvAcknowledgedData): break 759 | # append all text to data of first packet 760 | if data: 761 | result = data[0] 762 | for pkt in data[1:]: 763 | result.data += pkt.data 764 | self._set_result(result) 765 | 766 | def _handle_log(self, msg): 767 | if isinstance(msg, ChannelEvent) and msg.msg_id == 1: 768 | if msg.msg_code == EVENT_RX_SEARCH_TIMEOUT: 769 | _log.warning("RF channel timed out searching for device. channel_number=%d", msg.channel_number) 770 | elif msg.msg_code == EVENT_RX_FAIL: 771 | _log.warning("Failed to receive RF beacon at expected period. channel_number=%d", msg.channel_number) 772 | elif msg.msg_code == EVENT_RX_FAIL_GO_TO_SEARCH: 773 | _log.warning("Channel dropped to search do to too many dropped messages. channel_number=%d", msg.channel_number) 774 | elif msg.msg_code == EVENT_CHANNEL_COLLISION: 775 | _log.warning("Channel collision, another RF device intefered with channel. channel_number=%d", msg.channel_number) 776 | elif msg.msg_code == EVENT_SERIAL_QUE_OVERFLOW: 777 | _log.error("USB Serial buffer overflow. PC reading too slow.") 778 | 779 | def _set_result(self, result): 780 | """ 781 | Update the running command with given result, 782 | and set flag to indicate to caller that command 783 | is done. 784 | """ 785 | if self.running_cmd: 786 | cmd = self.running_cmd 787 | self.running_cmd = None 788 | cmd.result = result 789 | cmd.done.set() 790 | 791 | def _set_error(self, err): 792 | """ 793 | Update the running command with 794 | given exception. The exception will 795 | be raised to thread which invoked 796 | synchronous command. 797 | """ 798 | if self.running_cmd: 799 | cmd = self.running_cmd 800 | self.running_cmd = None 801 | cmd.error = err 802 | cmd.done.set() 803 | 804 | def loop(self): 805 | """ 806 | Message loop consuming data from the 807 | ANT device. Typically loop is started 808 | by thread created in Session.open() 809 | """ 810 | try: 811 | while self.running: 812 | for cmd in self.core.recv(): 813 | if not self.running: break 814 | self._handle_log(cmd) 815 | self._handle_read(cmd) 816 | self._handle_reply(cmd) 817 | self._handle_timeout() 818 | else: 819 | if not self.running: break 820 | self._handle_read() 821 | self._handle_timeout() 822 | except Exception: 823 | _log.error("Caught Exception handling message, session closing.", exc_info=True) 824 | finally: 825 | self.running_cmd = None 826 | self.running = False 827 | 828 | 829 | class Channel(object): 830 | 831 | def __init__(self, session, channel_number): 832 | self._session = session; 833 | self.channel_number = channel_number 834 | 835 | def open(self): 836 | self._session._send(OpenChannel(self.channel_number)) 837 | 838 | def close(self): 839 | self._session._send(CloseChannel(self.channel_number)) 840 | 841 | def assign(self, channel_type, network_number): 842 | self._session._send(AssignChannel(self.channel_number, channel_type, network_number)) 843 | 844 | def unassign(self): 845 | self._session._send(UnassignChannel(self.channel_number)) 846 | 847 | def set_id(self, device_number=0, device_type_id=0, trans_type=0): 848 | self._session._send(SetChannelId(self.channel_number, device_number, device_type_id, trans_type)) 849 | 850 | def set_period(self, messaging_period=8192): 851 | self._session._send(SetChannelPeriod(self.channel_number, messaging_period)) 852 | 853 | def set_search_timeout(self, search_timeout=12): 854 | self._session._send(SetChannelSearchTimeout(self.channel_number, search_timeout)) 855 | 856 | def set_rf_freq(self, rf_freq=66): 857 | self._session._send(SetChannelRfFreq(self.channel_number, rf_freq)) 858 | 859 | def set_search_waveform(self, search_waveform=None): 860 | if search_waveform is not None: 861 | self._session._send(SetSearchWaveform(self.channel_number, search_waveform)) 862 | 863 | def get_status(self): 864 | return self._session._send(RequestMessage(self.channel_number, ChannelStatus.ID)) 865 | 866 | def get_id(self): 867 | return self._session._send(RequestMessage(self.channel_number, ChannelId.ID)) 868 | 869 | def send_broadcast(self, data, timeout=None): 870 | if timeout is None: timeout = self._session.default_write_timeout 871 | data = data_tostring(data) 872 | assert len(data) <= 8 873 | self._session._send(SendBroadcastData(self.channel_number, data), timeout=timeout) 874 | 875 | def send_acknowledged(self, data, timeout=None, retry=None, direct=False): 876 | if timeout is None: timeout = self._session.default_write_timeout 877 | if retry is None: retry = self._session.default_retry 878 | data = data_tostring(data) 879 | assert len(data) <= 8 880 | cmd = SendAcknowledgedData(self.channel_number, data) 881 | if not direct: 882 | self._session._send(cmd, timeout=timeout, retry=retry) 883 | else: 884 | # force message tx regardless of command queue 885 | # state, and ignore result. usefully for best 886 | # attempt cleanup on exit. 887 | self._session.core.send(cmd) 888 | 889 | def send_burst(self, data, timeout=None, retry=None): 890 | if timeout is None: timeout = self._session.default_write_timeout 891 | if retry is None: retry = self._session.default_retry 892 | data = data_tostring(data) 893 | self._session._send(SendBurstData(self.channel_number, data), timeout=timeout, retry=retry) 894 | 895 | def recv_broadcast(self, timeout=None): 896 | if timeout is None: timeout = self._session.default_read_timeout 897 | return self._session._send(ReadData(self.channel_number, RecvBroadcastData), timeout=timeout).data 898 | 899 | def recv_acknowledged(self, timeout=None): 900 | if timeout is None: timeout = self._session.default_read_timeout 901 | return self._session._send(ReadData(self.channel_number, RecvAcknowledgedData), timeout=timeout).data 902 | 903 | def recv_burst(self, timeout=None): 904 | if timeout is None: timeout = self._session.default_read_timeout 905 | return self._session._send(ReadData(self.channel_number, RecvBurstTransferPacket), timeout=timeout).data 906 | 907 | def write(self, data, timeout=None, retry=None): 908 | if timeout is None: timeout = self._session.default_write_timeout 909 | if retry is None: retry = self._session.default_retry 910 | data = data_tostring(data) 911 | if len(data) <= 8: 912 | self.send_acknowledged(data, timeout=timeout, retry=retry) 913 | else: 914 | self.send_burst(data, timeout=timeout, retry=retry) 915 | 916 | def read(self, timeout=None): 917 | if timeout is None: timeout = self._session.default_read_timeout 918 | return self._session._send(ReadData(self.channel_number, ReadData), timeout=timeout).data 919 | 920 | class Network(object): 921 | 922 | def __init__(self, session, network_number): 923 | self._session = session 924 | self.network_number = network_number 925 | 926 | def set_key(self, network_key="\x00" * 8): 927 | self._session._send(SetNetworkKey(self.network_number, network_key)) 928 | 929 | 930 | # vim: ts=4 sts=4 et 931 | --------------------------------------------------------------------------------