├── .gitignore ├── 80-dexcom.rules ├── Dockerfile ├── README.md ├── dexcom_receiver.py ├── dexcom_share.py ├── dexpy.json ├── dexpy.py ├── docker-compose.yml ├── docker-entrypoint.sh ├── glucose.py ├── install.sh ├── requirements.txt └── usbreceiver ├── __init__.py ├── constants.py ├── crc16.py ├── database_records.py ├── packetwriter.py ├── readdata.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .vscode/ 3 | .idea/* 4 | -------------------------------------------------------------------------------- /80-dexcom.rules: -------------------------------------------------------------------------------- 1 | ######################################################################### 2 | ## This source file is from the openaps/dexcom_reader project. 3 | ## 4 | ## https://github.com/openaps/dexcom_reader 5 | ## 6 | ## It is under an MIT licence described in the 3 paragraphs below: 7 | ## 8 | ########################################################################## 9 | ## 10 | ## Permission is hereby granted, free of charge, to any person obtaining a 11 | ## copy of this software and associated documentation files (the "Software"), 12 | ## to deal in the Software without restriction, including without limitation 13 | ## the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | ## and/or sell copies of the Software, and to permit persons to whom the 15 | ## Software is furnished to do so, subject to the following conditions: 16 | ## 17 | ## The above copyright notice and this permission notice shall be included 18 | ## in all copies or substantial portions of the Software. 19 | ## 20 | ## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 21 | ## OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | ## FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 23 | ## THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 24 | ## OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 25 | ## ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 26 | ## OTHER DEALINGS IN THE SOFTWARE. 27 | ## 28 | ########################################################################## 29 | # 30 | # 31 | # 32 | ## udev can only match one entry at a time, but this triggers additional add entries. 33 | # 22a3:0047 34 | # 22a3 0047 35 | ATTRS{idVendor}=="22a3", ATTRS{idProduct}=="0047", \ 36 | ENV{ID_MM_DEVICE_IGNORE}="1", \ 37 | MODE="0664", GROUP="plugdev" 38 | 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | RUN apt update -y && apt upgrade -y 4 | RUN apt install -y udev ntp 5 | 6 | WORKDIR /usr/src/app 7 | RUN python3 -m pip install pip setuptools --upgrade 8 | RUN python3 -m pip install --no-cache-dir -r requirements.txt 9 | 10 | COPY . . 11 | RUN mkdir -p /etc/udev/rules.d 12 | RUN cp /usr/src/app/80-dexcom.rules /etc/udev/rules.d/ 13 | RUN chmod 755 /usr/src/app/entrypoint.sh 14 | 15 | CMD [ "./docker-entrypoint.sh" ] 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dexpy 2 | ## Why 3 | Many dexcom users get the official receiver as part of their standard kit. It is a neat little device that connects to the transmitter via bluetooth low energy and displays readings as they arrive. Unfortunately it lacks the connectivity options of the 21st century; there is no wireless, no bluetooth, no 2G/3G/4G, no nothing. On top of this, it is very sluggish to operate, has a very low quality display & touch screen and is extremely frustrating (to me personally) to unlock. 4 | 5 | Dexcom provides alternatively smartphone software, for only a limited number of "compatible" brands, which can do the same things that the receiver can and in addition use the smartphone environment to connect to online services for sharing data, analytics etc. There is however a big limitation of the dexcom provided smartphone software: You can only connect one smartphone to read the data from the CGM transmitter. That means increasing coverage for your CGM is not really an option by using multiple phones and the dexcom software. 6 | 7 | This limitation however does not apply when using the receiver, the smartphone and the receiver can both read data at the same time. 8 | 9 | ## What 10 | The purpose of this software is to make the receiver more useful by giving the users the option to connect and integrate it into other systems. It also provides an option to read data additionally from dexcom's own servers, if you happen to use the smartphone option, so you can consolidate data coming from two receivers in a single place. 11 | 12 | ## How 13 | In order to transfer the readings in real-time, you need to connect the receiver via usb to a computer where you run this software. It could be for example a raspberry pi (small and mobile) or just any computer in a fixed spot (say, livingroom?). 14 | 15 | # Features 16 | ## It can: 17 | - .. read sensor data directly from the dexcom receiver 18 | - .. read sensor data online from the dexcom share server (sharing must be enabled with the official dexcom app) 19 | - .. publish sensor data to a Nightscout instance 20 | - .. publish sensor data to an MQTT server 21 | - .. publish sensor data to an InfluxDB server 22 | - .. automatically transmit back-fill data as it becomes available 23 | ## It cannot: 24 | - Update data on the dexcom share server with receiver readings 25 | ## It will not: 26 | - Update data on the dexcom share server, because the hours long outage on new years eve of 2019 was simply unacceptable. (and it happened again in 2020 -so unexpectedly) 27 | 28 | # Setup 29 | * Download dexpy to your (preferably) debian based installation 30 | 31 | ``` 32 | sudo apt install -y git 33 | git clone https://github.com/winemug/dexpy.git 34 | cd dexpy 35 | ``` 36 | 37 | * Edit the configuration: 38 | ``` 39 | nano dexpy.json 40 | ``` 41 | 42 | Configuration example: 43 | ``` 44 | { 45 | "USB_RECEIVER": true, 46 | "DEXCOM_SHARE_SERVER": "eu", 47 | "DEXCOM_SHARE_USERNAME": "username", 48 | "DEXCOM_SHARE_PASSWORD": "password", 49 | "MQTT_SERVER": "mqtt.myserver.example", 50 | "MQTT_PORT": 1883, 51 | "MQTT_SSL": false, 52 | "MQTT_CLIENTID": "dexpy-mqtt-client", 53 | "MQTT_TOPIC": "cgm", 54 | "INFLUXDB_SERVER": "influxdb.myserver.example", 55 | "INFLUXDB_PORT": 8086, 56 | "INFLUXDB_SSL": false, 57 | "INFLUXDB_SSL_VERIFY": false, 58 | "INFLUXDB_USERNAME": "username", 59 | "INFLUXDB_PASSWORD": "password", 60 | "INFLUXDB_DATABASE": "dexpy", 61 | "INFLUXDB_MEASUREMENT": "bg", 62 | "NIGHTSCOUT_URL": "https://nightscout.myserver.example", 63 | "NIGHTSCOUT_SECRET": null, 64 | "NIGHTSCOUT_TOKEN": "ns-yadayadayada" 65 | } 66 | ``` 67 | 68 | * Run the install script that registers the usb device driver, downloads dependencies and starts dexpy as a systemd service 69 | ``` 70 | sudo ./install.sh 71 | ``` 72 | 73 | ### Reading from Dexcom Receiver via USB 74 | **USB_RECEIVER**: _true_ to enable reading from the receiver, otherwise _false_
75 | 76 | ### Reading from Dexcom Share online 77 | **DEXCOM_SHARE_SERVER**: "us" or "eu" based on your location, set to _null_ if you don't store your data in dexcom's cloud.
78 | **DEXCOM_SHARE_USERNAME**: Username for your dexcom share account.
79 | **DEXCOM_SHARE_PASSWORD**: Password for your dexcom share account.
80 | 81 | ### Sending data to an MQTT server 82 | **MQTT_SERVER**: Hostname for an MQTT server to post received glucose values or set to _null_ if not using mqtt
83 | **MQTT_PORT**: Port number for the mqtt server
84 | **MQTT_SSL**: _true_ if you're using ssl, otherwise _false_
85 | **MQTT_TOPIC**: Full name of the topic to post messages to
86 | 87 | ### Writing data to an Influx database 88 | **INFLUXDB_SERVER**: Hostname for your influxdb server or _null_ if not using influxdb
89 | **INFLUXDB_PORT**: Port for the http interface to your influxdb server
90 | **INFLUXDB_SSL**: _true_ if you're using ssl, otherwise _false_
91 | **INFLUXDB_SSL_VERIFY**: _true_ to enable certificate verification, otherwise _false_ (e.g. self-signed certificates)
92 | 93 | ### Sending data to a Nightscout instance 94 | **NIGHTSCOUT_URL**: Full url of your nightscout website (only root, no api links etc, i.e. https://mynightscout.azureblabla.local/) _null_ if not using nightscout.
95 | **NIGHTSCOUT_SECRET**: Password (the 12 character passphrase) used to access nightscout or if you're using a token, set to _null_
96 | **NIGHTSCOUT_TOKEN**: Enter the token you've generated using nightscout or if you're using the nightscout-secret option, set to _null_.
97 | 98 | Note: If you enable the "Dexcom Share Server" option, dexpy will read cgm data from dexcom's servers (whether it's available on the receiver or not) and publish it to other services you have configured. This is useful if you're using the Dexcom app on a phone to connect to the transmitter but want your data consolidated elsewhere. 99 | 100 | ## Run with docker (experimental) 101 | * Command line (to be described) 102 | ``` 103 | docker pull wynmug/dexpy 104 | ``` 105 | * Using docker-compose (to be described) 106 | ``` 107 | dexpy: 108 | image: wynmug/dexpy 109 | restart: always 110 | container_name: dexpy 111 | environment: 112 | - INFLUXDB_SERVER=influxdb 113 | - INFLUXDB_PORT=8086 114 | - INFLUXDB_USERNAME=username 115 | - INFLUXDB_PASSWORD=pwd 116 | - INFLUXDB_DATABASE=db 117 | - MQTT_SERVER= 118 | - MQTT_PORT= 119 | - MQTT_SSL= 120 | - MQTT_CLIENTID= 121 | - MQTT_TOPIC= 122 | ``` 123 | 124 | # Acknowledgements 125 | 126 | Dexcom Share protocol is implemented according to the [reverse engineering](https://gist.github.com/StephenBlackWasAlreadyTaken/adb0525344bedade1e25) performed by github user [StephenBlackWasAlreadyTaken](https://gist.github.com/StephenBlackWasAlreadyTaken) 127 | 128 | Dexcom Receiver code for communicating with the receiver via USB is borrowed from the [dexctrack](https://github.com/DexcTrack/dexctrack) project, which in turn is based on the [dexcom_reader](https://github.com/openaps/dexcom_reader) project. Further enhanced to support Dexcom G6 receiver backfill. 129 | -------------------------------------------------------------------------------- /dexcom_receiver.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from glucose import GlucoseValue 4 | import threading 5 | import logging 6 | 7 | from usbreceiver import constants 8 | from usbreceiver.readdata import Dexcom 9 | 10 | 11 | class DexcomReceiverSession(): 12 | def __init__(self, callback, usb_reset_cmd = None): 13 | self.logger = logging.getLogger('DEXPY') 14 | self.callback = callback 15 | self.device = None 16 | self.timer = None 17 | self.lock = threading.RLock() 18 | self.initial_backfill_executed = False 19 | self.last_gv = None 20 | self.system_time_offset = None 21 | self.usb_reset_cmd = usb_reset_cmd 22 | self.ts_usb_reset = time.time() + 360 23 | 24 | def start_monitoring(self): 25 | self.on_timer() 26 | 27 | def on_timer(self): 28 | with self.lock: 29 | if not self.ensure_connected(): 30 | self.set_timer(15) 31 | elif self.read_glucose_values(): 32 | self.ts_usb_reset = time.time() + 360 33 | self.set_timer(30) 34 | else: 35 | if self.usb_reset_cmd is not None: 36 | ts_now = time.time() 37 | if ts_now > self.ts_usb_reset: 38 | self.logger.debug('performing usb reset') 39 | os.system(self.usb_reset_cmd) 40 | ts_now = time.time() 41 | self.ts_usb_reset = ts_now + 360 42 | self.set_timer(10) 43 | 44 | def ensure_connected(self): 45 | try: 46 | if self.device is None: 47 | port = Dexcom.FindDevice() 48 | if port is None: 49 | self.logger.warning("Dexcom receiver not found") 50 | return False 51 | else: 52 | self.device = Dexcom(port) 53 | self.system_time_offset = self.get_device_time_offset() 54 | return True 55 | except Exception as e: 56 | self.logger.warning("Error reading from usb device\n" + str(e)) 57 | self.device = None 58 | self.system_time_offset = None 59 | return False 60 | 61 | def set_timer(self, seconds): 62 | self.timer = threading.Timer(seconds, self.on_timer) 63 | self.timer.setDaemon(True) 64 | self.logger.debug("timer set to %d seconds" % seconds) 65 | self.timer.start() 66 | 67 | def stop_monitoring(self): 68 | with self.lock: 69 | self.timer.cancel() 70 | 71 | def read_glucose_values(self, ts_cut_off: float = None): 72 | try: 73 | if ts_cut_off is None: 74 | if self.initial_backfill_executed: 75 | ts_cut_off = time.time() - 3 * 60 * 60 76 | else: 77 | ts_cut_off = time.time() - 24 * 60 * 60 78 | 79 | records = self.device.iter_records('EGV_DATA') 80 | new_value_received = False 81 | 82 | for rec in records: 83 | if not rec.display_only: 84 | gv = self._as_gv(rec) 85 | if self.last_gv is None or self.last_gv.st != gv.st: 86 | self.last_gv = gv 87 | new_value_received = True 88 | self.callback([gv]) 89 | break 90 | 91 | if new_value_received: 92 | for rec in records: 93 | if not rec.display_only: 94 | gv = self._as_gv(rec) 95 | if gv.st >= ts_cut_off: 96 | self.callback([gv]) 97 | else: 98 | break 99 | 100 | for rec in self.device.iter_records('BACKFILLED_EGV'): 101 | if not rec.display_only: 102 | gv = self._as_gv(rec) 103 | if gv.st >= ts_cut_off: 104 | self.callback([gv]) 105 | else: 106 | break 107 | 108 | self.initial_backfill_executed = True 109 | return new_value_received 110 | except Exception as e: 111 | self.logger.warning("Error reading from usb device\n" + str(e)) 112 | return False 113 | 114 | def get_device_time_offset(self): 115 | now_time = time.time() 116 | device_time = self.device.ReadSystemTime() 117 | return now_time - device_time 118 | 119 | def _as_gv(self, record): 120 | st = record.meter_time + self.system_time_offset 121 | direction = record.full_trend & constants.EGV_TREND_ARROW_MASK 122 | return GlucoseValue(None, None, st, record.glucose, direction) 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /dexcom_share.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import requests 4 | import json 5 | from glucose import GlucoseValue 6 | import time 7 | 8 | 9 | # Dexcom Share API credits: 10 | # https://gist.github.com/StephenBlackWasAlreadyTaken/adb0525344bedade1e25 11 | 12 | class DexcomShareSession(): 13 | def __init__(self, location, username, password, callback): 14 | self.logger = logging.getLogger('DEXPY') 15 | self.callback = callback 16 | 17 | if location == "us": 18 | self.address = "share2.dexcom.com" 19 | elif location == "eu": 20 | self.address = "shareous1.dexcom.com" 21 | else: 22 | raise ValueError("Unknown location type") 23 | 24 | self.username = username 25 | self.password = password 26 | self.session = None 27 | self.dexcom_session_id = None 28 | 29 | self.lock = threading.RLock() 30 | self.timer = None 31 | self.initial_backfill_executed = False 32 | self.gvs = [] 33 | 34 | def start_monitoring(self): 35 | self.session = requests.Session() 36 | self.logger.info("started dexcom share client") 37 | self.on_timer() 38 | 39 | def stop_monitoring(self): 40 | with self.lock: 41 | if self.timer is not None: 42 | self.timer.cancel() 43 | self.timer = None 44 | 45 | self.session.close() 46 | 47 | def on_timer(self): 48 | with self.lock: 49 | request_wait = self.perform_request() 50 | self.logger.debug("next request in %d seconds" % request_wait) 51 | self.timer = threading.Timer(request_wait, self.on_timer) 52 | self.timer.setDaemon(True) 53 | self.timer.start() 54 | 55 | def perform_request(self) -> float: 56 | if self.dexcom_session_id is None: 57 | self.login() 58 | 59 | if self.dexcom_session_id is None: 60 | return 30 61 | 62 | self.logger.debug("Requesting glucose value") 63 | gv = self.get_last_gv() 64 | if gv is None: 65 | self.logger.warning("Received no glucose value") 66 | else: 67 | if len(self.gvs) == 0 or self.gvs[-1].__ne__(gv): 68 | self.gvs.append(gv) 69 | self.callback([gv]) 70 | self.backfill() 71 | if gv is None: 72 | return 60 73 | 74 | time_since = time.time() - gv.st 75 | g6_phase = time_since % 300 76 | if time_since < 330: 77 | return 330 - time_since 78 | elif g6_phase < 90: 79 | return 15 80 | else: 81 | return 330 - g6_phase 82 | 83 | def backfill(self): 84 | cut_off = time.time() - 3 * 60 * 60 - 5 * 60 85 | self.gvs = [gv for gv in self.gvs if gv.st > cut_off] 86 | 87 | if self.initial_backfill_executed and len(self.gvs) >= 36: 88 | return 89 | 90 | if self.initial_backfill_executed: 91 | self.logger.info("Missing measurements within the last 3 hours, attempting to backfill..") 92 | gvs = self.get_gvs(180, 40) 93 | else: 94 | self.logger.info("Executing initial backfill with the last 24 hours of data..") 95 | gvs = self.get_gvs(1440, 300) 96 | 97 | if gvs is None: 98 | self.logger.warning("No data received") 99 | return 100 | 101 | self.initial_backfill_executed = True 102 | self.logger.debug("Received %d glucose values from history" % len(gvs)) 103 | self.callback(gvs) 104 | self.gvs = gvs 105 | 106 | def login(self): 107 | url = "https://%s/ShareWebServices/Services/General/LoginPublisherAccountByName" % self.address 108 | headers = {"Accept": "application/json", 109 | "Content-Type": "application/json", 110 | "User-Agent": "Dexcom Share/3.0.2.11 CFNetwork/711.2.23 Darwin/14.0.0"} 111 | payload = {"accountName": self.username, 112 | "password": self.password, 113 | "applicationId": "d8665ade-9673-4e27-9ff6-92db4ce13d13"} 114 | 115 | self.logger.debug("Attempting to login") 116 | result = None 117 | try: 118 | result = self.session.post(url, data=json.dumps(payload), headers=headers) 119 | except Exception as e: 120 | self.logger.error(e) 121 | 122 | if result is None or result.status_code != 200: 123 | self.recreate_session() 124 | self.logger.error("Login failed") 125 | else: 126 | self.dexcom_session_id = result.text[1:-1] 127 | self.logger.info("Login successful, session id: %s" % self.dexcom_session_id) 128 | 129 | def recreate_session(self): 130 | try: 131 | self.session.close() 132 | except Exception as ex: 133 | self.logger.warning("Error while closing session", exc_info=ex) 134 | self.session = requests.Session() 135 | 136 | def get_gvs(self, minutes, maxCount): 137 | url = "https://%s/ShareWebServices/Services/Publisher/ReadPublisherLatestGlucoseValues" % self.address 138 | url += "?sessionId=%s&minutes=%d&maxCount=%d" % (self.dexcom_session_id, minutes, maxCount) 139 | headers = {"Accept": "application/json", 140 | "User-Agent": "Dexcom Share/3.0.2.11 CFNetwork/711.2.23 Darwin/14.0.0"} 141 | result = None 142 | try: 143 | result = self.session.post(url, headers=headers) 144 | except Exception as ex: 145 | self.logger.error(exc_info=ex) 146 | 147 | gvs = [] 148 | if result is not None and result.status_code == 200: 149 | for jsonResult in result.json(): 150 | gvs.append(GlucoseValue.from_json(jsonResult)) 151 | return gvs 152 | else: 153 | self.recreate_session() 154 | return None 155 | 156 | def get_last_gv(self): 157 | r = self.get_gvs(1440, 1) 158 | if r is not None and len(r) > 0: 159 | return r[0] 160 | return None 161 | -------------------------------------------------------------------------------- /dexpy.json: -------------------------------------------------------------------------------- 1 | { 2 | "USB_RECEIVER": true, 3 | "DEXCOM_SHARE_SERVER": "eu", 4 | "DEXCOM_SHARE_USERNAME": "username", 5 | "DEXCOM_SHARE_PASSWORD": "password", 6 | "MQTT_SERVER": null, 7 | "MQTT_PORT": null, 8 | "MQTT_SSL": false, 9 | "MQTT_CLIENTID": "", 10 | "MQTT_TOPIC": "", 11 | "INFLUXDB_SERVER": null, 12 | "INFLUXDB_PORT": 8086, 13 | "INFLUXDB_SSL": true, 14 | "INFLUXDB_SSL_VERIFY": true, 15 | "INFLUXDB_USERNAME": "", 16 | "INFLUXDB_PASSWORD": "", 17 | "INFLUXDB_DATABASE": "dexpy", 18 | "INFLUXDB_MEASUREMENT": "bg", 19 | "NIGHTSCOUT_URL": null, 20 | "NIGHTSCOUT_SECRET": null, 21 | "NIGHTSCOUT_TOKEN": null 22 | } -------------------------------------------------------------------------------- /dexpy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse 3 | import bisect 4 | import datetime as dt 5 | import logging 6 | import signal 7 | import sqlite3 8 | import ssl 9 | import threading 10 | from queue import Queue, Empty 11 | 12 | import paho.mqtt.client as mqttc 13 | import requests 14 | import simplejson as json 15 | from influxdb import InfluxDBClient 16 | from paho.mqtt.client import MQTTv311 17 | 18 | from dexcom_receiver import DexcomReceiverSession 19 | from dexcom_share import DexcomShareSession 20 | import os 21 | import distro 22 | 23 | class DexPy: 24 | def __init__(self, args): 25 | self.logger = logging.getLogger('DEXPY') 26 | 27 | self.args = args 28 | self.exit_event = threading.Event() 29 | self.message_published_event = threading.Event() 30 | 31 | self.initialize_db() 32 | self.mqtt_client = None 33 | if args.MQTT_SERVER is not None: 34 | self.mqtt_client = mqttc.Client(client_id=args.MQTT_CLIENTID, clean_session=True, protocol=MQTTv311, 35 | transport="tcp") 36 | 37 | if args.MQTT_SSL != "": 38 | self.mqtt_client.tls_set(certfile=None, 39 | keyfile=None, cert_reqs=ssl.CERT_REQUIRED, 40 | tls_version=ssl.PROTOCOL_TLSv1_2, ciphers=None) 41 | self.mqtt_client.tls_insecure_set(True) 42 | 43 | self.mqtt_client.on_connect = self.on_mqtt_connect 44 | self.mqtt_client.on_disconnect = self.on_mqtt_disconnect 45 | self.mqtt_client.on_message = self.on_mqtt_message_receive 46 | self.mqtt_client.on_publish = self.on_mqtt_message_publish 47 | 48 | self.influx_client = None 49 | if self.args.INFLUXDB_SERVER is not None: 50 | self.influx_client = InfluxDBClient(self.args.INFLUXDB_SERVER, self.args.INFLUXDB_PORT, self.args.INFLUXDB_USERNAME, 51 | self.args.INFLUXDB_PASSWORD, self.args.INFLUXDB_DATABASE, 52 | ssl=self.args.INFLUXDB_SSL, verify_ssl=self.args.INFLUXDB_SSL_VERIFY) 53 | 54 | self.callback_queue = Queue() 55 | self.glucose_values = [] 56 | self.mqtt_pending = {} 57 | self.influx_pending = [] 58 | self.ns_pending = [] 59 | 60 | self.ns_session = None 61 | if self.args.NIGHTSCOUT_URL is not None: 62 | self.ns_session = requests.Session() 63 | 64 | self.dexcom_share_session = None 65 | if self.args.DEXCOM_SHARE_SERVER is not None: 66 | self.logger.info("starting dexcom share session") 67 | self.dexcom_share_session = DexcomShareSession(self.args.DEXCOM_SHARE_SERVER, 68 | self.args.DEXCOM_SHARE_USERNAME, 69 | self.args.DEXCOM_SHARE_PASSWORD, 70 | self.glucose_values_received) 71 | 72 | self.dexcom_receiver_session = None 73 | if self.args.USB_RECEIVER is not None and self.args.USB_RECEIVER: 74 | self.dexcom_receiver_session = DexcomReceiverSession(self.glucose_values_received, self.args.USB_RESET_COMMAND) 75 | 76 | for sig in ('HUP', 'INT'): 77 | signal.signal(getattr(signal, 'SIG' + sig), lambda _0, _1: self.exit_event.set()) 78 | 79 | def run(self): 80 | if self.mqtt_client is not None: 81 | self.logger.info("starting mqtt service connection") 82 | self.mqtt_client.reconnect_delay_set(min_delay=15, max_delay=120) 83 | self.mqtt_client.connect_async(self.args.MQTT_SERVER, port=self.args.MQTT_PORT, keepalive=60) 84 | self.mqtt_client.retry_first_connection = True 85 | self.mqtt_client.loop_start() 86 | 87 | if self.dexcom_share_session is not None: 88 | self.logger.info("starting monitoring dexcom share server") 89 | self.dexcom_share_session.start_monitoring() 90 | 91 | if self.dexcom_receiver_session is not None: 92 | self.logger.info("starting usb receiver service") 93 | self.dexcom_receiver_session.start_monitoring() 94 | 95 | queue_thread = threading.Thread(target=self.queue_handler) 96 | queue_thread.start() 97 | 98 | try: 99 | while not self.exit_event.wait(timeout=1000): 100 | pass 101 | except KeyboardInterrupt: 102 | pass 103 | 104 | self.exit_event.clear() 105 | if self.dexcom_receiver_session is not None: 106 | self.logger.info("stopping dexcom receiver service") 107 | self.dexcom_receiver_session.stop_monitoring() 108 | 109 | if self.dexcom_share_session is not None: 110 | self.logger.info("stopping listening on dexcom share server") 111 | self.dexcom_share_session.stop_monitoring() 112 | 113 | if self.mqtt_client is not None: 114 | self.logger.info("stopping mqtt client") 115 | self.mqtt_client.loop_stop() 116 | self.mqtt_client.disconnect() 117 | 118 | if self.influx_client is not None: 119 | self.logger.info("closing influxdb client") 120 | self.influx_client.close() 121 | 122 | if self.ns_session is not None: 123 | self.logger.info("closing nightscout session") 124 | self.ns_session.close() 125 | 126 | def on_mqtt_connect(self, client, userdata, flags, rc): 127 | self.logger.info("Connected to mqtt server with result code " + str(rc)) 128 | self.logger.debug("Pending %d messages in local queue" % len(self.mqtt_pending)) 129 | 130 | def on_mqtt_disconnect(self, client, userdata, rc): 131 | self.logger.info("Disconnected from mqtt with result code " + str(rc)) 132 | self.logger.debug("Pending %d messages in local queue" % len(self.mqtt_pending)) 133 | 134 | def on_mqtt_message_receive(self, client, userdata, msg): 135 | self.logger.info("mqtt message received: " + msg) 136 | 137 | def on_mqtt_message_publish(self, client, userdata, msg_id): 138 | self.logger.info("mqtt message published: " + str(msg_id)) 139 | if msg_id in self.mqtt_pending: 140 | self.mqtt_pending.pop(msg_id) 141 | else: 142 | self.logger.debug("unknown message id: " + str(msg_id)) 143 | self.logger.debug("Pending %d messages in local queue" % len(self.mqtt_pending)) 144 | 145 | def glucose_values_received(self, gvs): 146 | for gv in gvs: 147 | self.callback_queue.put(gv) 148 | 149 | def queue_handler(self): 150 | while not self.exit_event.wait(timeout=0.200): 151 | gvs = [] 152 | while True: 153 | try: 154 | gv = self.callback_queue.get(block=True, timeout=5) 155 | gvs.append(gv) 156 | except Empty: 157 | if len(gvs) > 0: 158 | self.process_glucose_values(gvs) 159 | gvs = [] 160 | 161 | def process_glucose_values(self, gvs): 162 | new_values = [] 163 | for gv in gvs: 164 | new_val = True 165 | for gv_check in self.glucose_values: 166 | if gv_check == gv: 167 | new_val = False 168 | break 169 | if new_val: 170 | new_values.append(gv) 171 | self.logger.info(f"New gv: {gv}") 172 | 173 | if self.mqtt_client is not None: 174 | for gv in new_values: 175 | msg = "%d|%s|%s" % (gv.st, gv.trend, gv.value) 176 | x, mid = self.mqtt_client.publish(self.args.MQTT_TOPIC, payload=msg, qos=1) 177 | self.mqtt_pending[mid] = gv 178 | self.logger.debug("publish to mqtt requested with message id: " + str(mid)) 179 | 180 | if self.influx_client is not None: 181 | for gv in new_values: 182 | point = { 183 | "measurement": self.args.INFLUXDB_MEASUREMENT, 184 | "tags": {"device": "dexcomg6", "source": "dexpy"}, 185 | "time": dt.datetime.utcfromtimestamp(gv.st).strftime("%Y-%m-%dT%H:%M:%SZ"), 186 | "fields": {"cbg": float(gv.value), "direction": int(gv.trend)} 187 | } 188 | self.influx_pending.append(point) 189 | try: 190 | if self.influx_client.write_points(self.influx_pending): 191 | self.influx_pending = [] 192 | except Exception as ex: 193 | self.logger.error("Error writing to influxdb", exc_info=ex) 194 | 195 | if self.ns_session is not None: 196 | apiUrl = self.args.NIGHTSCOUT_URL 197 | if apiUrl[-1] != "/": 198 | apiUrl += "/" 199 | apiUrl += "api/v1/entries/" 200 | headers = {"Content-Type": "application/json"} 201 | if self.args.NIGHTSCOUT_SECRET: 202 | headers["api-secret"] = self.args.NIGHTSCOUT_SECRET 203 | if self.args.NIGHTSCOUT_TOKEN: 204 | apiUrl += "?token=" + self.args.NIGHTSCOUT_TOKEN 205 | 206 | for gv in new_values: 207 | payload = {"sgv": gv.value, "type": "sgv", "direction": gv.trend_string(), "date": gv.st * 1000} 208 | self.ns_pending.append(json.dumps(payload)) 209 | posted_entries = [] 210 | for pendingEntry in self.ns_pending: 211 | try: 212 | response = self.ns_session.post(apiUrl, headers=headers, data=pendingEntry) 213 | if response is not None and response.status_code == 200: 214 | posted_entries.append(pendingEntry) 215 | else: 216 | self.logger.error(f"NS server returned invalid response {response}") 217 | except Exception as ex: 218 | self.logger.error("Error posting value to nightscout", ex) 219 | for posted_entry in posted_entries: 220 | self.ns_pending.remove(posted_entry) 221 | 222 | for gv in new_values: 223 | i = bisect.bisect_right(self.glucose_values, gv) 224 | self.glucose_values.insert(i + 1, gv) 225 | 226 | if len(self.glucose_values) > 4096: 227 | self.glucose_values = self.glucose_values[4096 - len(self.glucose_values):] 228 | 229 | def initialize_db(self): 230 | try: 231 | with sqlite3.connect(self.args.DB_PATH) as conn: 232 | sql = """ CREATE TABLE IF NOT EXISTS gv ( 233 | ts REAL, 234 | gv REAL, 235 | trend TEXT 236 | ) """ 237 | conn.execute(sql) 238 | 239 | sql = """ CREATE INDEX "idx_ts" ON "gv" ("ts");""" 240 | try: 241 | conn.execute(sql) 242 | except: 243 | self.logger.debug("Index creation skipped") 244 | 245 | except Exception as ex: 246 | self.logger.warning("Error initializing local db", exc_info=ex) 247 | 248 | 249 | if __name__ == '__main__': 250 | logger = logging.getLogger('DEXPY') 251 | logger.setLevel(logging.DEBUG) 252 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 253 | 254 | ch = logging.StreamHandler() 255 | ch.setLevel(logging.DEBUG) 256 | ch.setFormatter(formatter) 257 | logger.addHandler(ch) 258 | 259 | parser = argparse.ArgumentParser() 260 | parser.add_argument("--CONFIGURATION", required=False, default=None, nargs="?") 261 | parser.add_argument("--DEXCOM-SHARE-SERVER", required=False, default=None, nargs="?") 262 | parser.add_argument("--DEXCOM-SHARE-USERNAME", required=False, default="", nargs="?") 263 | parser.add_argument("--DEXCOM-SHARE-PASSWORD", required=False, default="", nargs="?") 264 | parser.add_argument("--MQTT-SERVER", required=False, default=None, nargs="?") 265 | parser.add_argument("--MQTT-PORT", required=False, default="1881", nargs="?") 266 | parser.add_argument("--MQTT-SSL", required=False, default="", nargs="?") 267 | parser.add_argument("--MQTT-CLIENTID", required=False, default="dexpy", nargs="?") 268 | parser.add_argument("--MQTT-TOPIC", required=False, default="cgm", nargs="?") 269 | parser.add_argument("--INFLUXDB-SERVER", required=False, default=None, nargs="?") 270 | parser.add_argument("--INFLUXDB-PORT", required=False, default="8086", nargs="?") 271 | parser.add_argument("--INFLUXDB-SSL", required=False, default=False, nargs="?") 272 | parser.add_argument("--INFLUXDB-SSL-VERIFY", required=False, default=False, nargs="?") 273 | parser.add_argument("--INFLUXDB-USERNAME", required=False, default="", nargs="?") 274 | parser.add_argument("--INFLUXDB-PASSWORD", required=False, default="", nargs="?") 275 | parser.add_argument("--INFLUXDB-DATABASE", required=False, default="", nargs="?") 276 | parser.add_argument("--INFLUXDB-MEASUREMENT", required=False, default="", nargs="?") 277 | parser.add_argument("--NIGHTSCOUT-URL", required=False, default=None, nargs="?") 278 | parser.add_argument("--NIGHTSCOUT-SECRET", required=False, default=None, nargs="?") 279 | parser.add_argument("--NIGHTSCOUT-TOKEN", required=False, default=None, nargs="?") 280 | parser.add_argument("--DB-PATH", required=False, default="dexpy.db", nargs="?") 281 | parser.add_argument("--USB-RECEIVER", required=False, default=True, nargs="?") 282 | parser.add_argument("--USB-RESET-COMMAND", required=False, default=None, nargs="?") 283 | 284 | args = parser.parse_args() 285 | 286 | if args.CONFIGURATION is not None: 287 | with open(args.CONFIGURATION, 'r') as stream: 288 | js = json.load(stream) 289 | 290 | for js_arg in js: 291 | args.__dict__[js_arg] = js[js_arg] 292 | 293 | dexpy = DexPy(args) 294 | dexpy.run() 295 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | services: 3 | dexpy: 4 | build: ./ 5 | image: dexpy:latest 6 | privileged: True 7 | volumes: 8 | - /dev/bus/usb:/dev/bus/usb 9 | environment: 10 | - DEXCOM_SHARE_SERVER= 11 | - DEXCOM_SHARE_USERNAME= 12 | - DEXCOM_SHARE_PASSWORD= 13 | - MQTT_SERVER= 14 | - MQTT_PORT= 15 | - MQTT_SSL= 16 | - MQTT_CLIENTID= 17 | - MQTT_TOPIC= 18 | - INFLUXDB_SERVER= 19 | - INFLUXDB_PORT= 20 | - INFLUXDB_SSL= 21 | - INFLUXDB_USERNAME= 22 | - INFLUXDB_PASSWORD= 23 | - INFLUXDB_DATABASE= 24 | - NIGHTSCOUT_URL= 25 | - NIGHTSCOUT_SECRET= 26 | - NIGHTSCOUT_TOKEN= 27 | - LOG_LEVEL=DEBUG 28 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | python3 dexpy.py \ 2 | --DEXCOM-SHARE-SERVER $DEXCOM_SHARE_SERVER \ 3 | --DEXCOM-SHARE-USERNAME $DEXCOM_SHARE_USERNAME \ 4 | --DEXCOM-SHARE-PASSWORD $DEXCOM_SHARE_PASSWORD \ 5 | --MQTT-SERVER $MQTT_SERVER \ 6 | --MQTT-PORT $MQTT_PORT \ 7 | --MQTT-SSL $MQTT_SSL \ 8 | --MQTT-CLIENTID $MQTT_CLIENTID \ 9 | --MQTT-TOPIC $MQTT_TOPIC \ 10 | --INFLUXDB-SERVER $INFLUXDB_SERVER \ 11 | --INFLUXDB-PORT $INFLUXDB_PORT \ 12 | --INFLUXDB-SSL $INFLUXDB_SSL \ 13 | --INFLUXDB-USERNAME $INFLUXDB_USERNAME \ 14 | --INFLUXDB-PASSWORD $INFLUXDB_PASSWORD \ 15 | --INFLUXDB-DATABASE $INFLUXDB_DATABASE \ 16 | --NIGHTSCOUT-URL $NIGHTSCOUT_URL \ 17 | --NIGHTSCOUT-SECRET $NIGHTSCOUT_SECRET \ 18 | --NIGHTSCOUT-TOKEN $NIGHTSCOUT_TOKEN \ 19 | --LOG-LEVEL $LOG_LEVEL 20 | -------------------------------------------------------------------------------- /glucose.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | NightscoutTrendStrings = ['None', 'DoubleUp', 'SingleUp', 'FortyFiveUp', 'Flat', 'FortyFiveDown', 'SingleDown', 'DoubleDown', 'NotComputable', 'OutOfRange'] 5 | 6 | 7 | def _as_ts(val): 8 | res = re.search("Date\\((\\d*)", val) 9 | return float(res.group(1)) / 1000 10 | 11 | 12 | class GlucoseValue(): 13 | def __init__(self, dt, wt, st, value, trend): 14 | self.logger = logging.getLogger('DEXPY') 15 | self.dt = dt 16 | self.wt = wt 17 | self.st = st 18 | self.value = value 19 | self.trend = trend 20 | 21 | def trend_string(self): 22 | return NightscoutTrendStrings[self.trend] 23 | 24 | @staticmethod 25 | def from_json(jsonResponse, timeoffset=0): 26 | dt = _as_ts(jsonResponse["DT"]) + timeoffset 27 | wt = _as_ts(jsonResponse["WT"]) + timeoffset 28 | st = _as_ts(jsonResponse["ST"]) + timeoffset 29 | value = float(jsonResponse["Value"]) 30 | trend = int(jsonResponse["Trend"]) 31 | return GlucoseValue(dt, wt, st, value, trend) 32 | 33 | def __eq__(self, other): 34 | return self.same_ts(other) and self.same_val(other) 35 | 36 | def __ne__(self, other): 37 | return not self.__eq__(other) 38 | 39 | def __gt__(self, other): 40 | return self.__ne__(other) and self.st > other.st 41 | 42 | def __lt__(self, other): 43 | return self.__ne__(other) and self.st < other.st 44 | 45 | def __ge__(self, other): 46 | return self.__eq__(other) or self.st > other.st 47 | 48 | def __le__(self, other): 49 | return self.__eq__(other) or self.st < other.st 50 | 51 | def same_ts(self, other): 52 | seconds_diff = self.st - other.st 53 | return abs(seconds_diff) < 240 54 | 55 | def same_val(self, other): 56 | return int(round(self.value)) == int(round(other.value)) 57 | 58 | def equals(self, other): 59 | seconds_difference = abs((self.st - other.st)) 60 | if seconds_difference >= 240: 61 | return False 62 | if self.trend != other.trend: 63 | return False 64 | if int(round(self.value)) != int(round(other.value)): 65 | return False 66 | 67 | return True 68 | 69 | def __str__(self): 70 | return "DT: %s WT: %s ST: %s Trend: %s Value: %f" % (self.dt, self.wt, self.st, self.trend_string(), self.value) -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | apt update 3 | apt install -y udev python3 python3-pip python3-venv 4 | python3 -m venv venv 5 | ./venv/bin/python3 -m pip install pip setuptools --upgrade 6 | ./venv/bin/python3 -m pip install -r requirements.txt --upgrade 7 | mkdir -p /etc/udev/rules.d 8 | cp 80-dexcom.rules /etc/udev/rules.d/ 9 | echo " 10 | [Unit] 11 | Description=Dexpy 12 | After=network.target 13 | 14 | [Service] 15 | ExecStart=$(pwd)/venv/bin/python3 dexpy.py --CONFIGURATION dexpy.json 16 | WorkingDirectory=$(pwd) 17 | StandardOutput=inherit 18 | StandardError=inherit 19 | TimeoutStopSec=30 20 | Restart=on-abort 21 | User=$(logname) 22 | 23 | [Install] 24 | WantedBy=multi-user.target" > dexpy.service 25 | cp dexpy.service /etc/systemd/system/ 26 | systemctl enable dexpy.service 27 | systemctl start dexpy.service 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paho_mqtt==1.5.1 2 | pyserial==3.4 3 | requests==2.24.0 4 | influxdb==5.3.0 5 | simplejson 6 | distro 7 | -------------------------------------------------------------------------------- /usbreceiver/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/winemug/dexpy/72f3f3ca4adf6cb773484d05c0042f01f5b8c857/usbreceiver/__init__.py -------------------------------------------------------------------------------- /usbreceiver/constants.py: -------------------------------------------------------------------------------- 1 | ######################################################################### 2 | # This source file is from the openaps/dexcom_reader project. 3 | # 4 | # https://github.com/openaps/dexcom_reader 5 | # 6 | # It is under an MIT licence described in the 3 paragraphs below: 7 | # 8 | ######################################################################### 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a 11 | # copy of this software and associated documentation files (the "Software"), 12 | # to deal in the Software without restriction, including without limitation 13 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | # and/or sell copies of the Software, and to permit persons to whom the 15 | # Software is furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included 18 | # in all copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 21 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 23 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 24 | # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 25 | # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 26 | # OTHER DEALINGS IN THE SOFTWARE. 27 | # 28 | ######################################################################### 29 | 30 | import datetime 31 | 32 | 33 | class Error(Exception): 34 | """Base error for dexcom reader.""" 35 | 36 | 37 | class CrcError(Error): 38 | """Failed to CRC properly.""" 39 | 40 | 41 | DEXCOM_G4_USB_VENDOR = 0x22a3 42 | DEXCOM_G4_USB_PRODUCT = 0x0047 43 | 44 | BASE_TIME = datetime.datetime(2009, 1, 1) 45 | DEXCOM_EPOCH = 1230768000 46 | 47 | NULL = 0 48 | ACK = 1 49 | NAK = 2 50 | INVALID_COMMAND = 3 51 | INVALID_PARAM = 4 52 | INCOMPLETE_PACKET_RECEIVED = 5 53 | RECEIVER_ERROR = 6 54 | INVALID_MODE = 7 55 | PING = 10 56 | READ_FIRMWARE_HEADER = 11 57 | READ_DATABASE_PARTITION_INFO = 15 58 | READ_DATABASE_PAGE_RANGE = 16 59 | READ_DATABASE_PAGES = 17 60 | READ_DATABASE_PAGE_HEADER = 18 61 | READ_TRANSMITTER_ID = 25 62 | WRITE_TRANSMITTER_ID = 26 63 | READ_LANGUAGE = 27 64 | WRITE_LANGUAGE = 28 65 | READ_DISPLAY_TIME_OFFSET = 29 66 | WRITE_DISPLAY_TIME_OFFSET = 30 67 | READ_RTC = 31 68 | RESET_RECEIVER = 32 69 | READ_BATTERY_LEVEL = 33 70 | READ_SYSTEM_TIME = 34 71 | READ_SYSTEM_TIME_OFFSET = 35 72 | WRITE_SYSTEM_TIME = 36 73 | READ_GLUCOSE_UNIT = 37 74 | WRITE_GLUCOSE_UNIT = 38 75 | READ_BLINDED_MODE = 39 76 | WRITE_BLINDED_MODE = 40 77 | READ_CLOCK_MODE = 41 78 | WRITE_CLOCK_MODE = 42 79 | READ_DEVICE_MODE = 43 80 | ERASE_DATABASE = 45 81 | SHUTDOWN_RECEIVER = 46 82 | WRITE_PC_PARAMETERS = 47 83 | READ_BATTERY_STATE = 48 84 | READ_HARDWARE_BOARD_ID = 49 85 | READ_FIRMWARE_SETTINGS = 54 86 | READ_ENABLE_SETUP_WIZARD_FLAG = 55 87 | READ_SETUP_WIZARD_STATE = 57 88 | READ_CHARGER_CURRENT_SETTING = 59 89 | WRITE_CHARGER_CURRENT_SETTING = 60 90 | MAX_COMMAND = 61 91 | MAX_POSSIBLE_COMMAND = 255 92 | 93 | EGV_VALUE_MASK = 1023 94 | EGV_DISPLAY_ONLY_MASK = 32768 95 | EGV_TREND_ARROW_MASK = 15 96 | 97 | BATTERY_STATES = [None, 'CHARGING', 'NOT_CHARGING', 'NTC_FAULT', 'BAD_BATTERY'] 98 | 99 | 100 | RECORD_TYPES = [ 101 | 'MANUFACTURING_DATA', 'FIRMWARE_PARAMETER_DATA', 'PC_SOFTWARE_PARAMETER', 102 | 'SENSOR_DATA', 'EGV_DATA', 'CAL_SET', 'ABERRATION', 'INSERTION_TIME', 103 | 'RECEIVER_LOG_DATA', 'RECEIVER_ERROR_DATA', 'METER_DATA', 'USER_EVENT_DATA', 104 | 'USER_SETTING_DATA', 'SESSION_COMMAND_DATA', 'TRANSMITTER_INFO_DATA', 'UNKNOWN', 'TRANSMITTER_PRIVATE_DATA', 105 | 'TRANSMITTER_MANIFEST', 'BACKFILLED_EGV' 106 | ] 107 | 108 | TREND_ARROW_VALUES = [None, 'DOUBLE_UP', 'SINGLE_UP', '45_UP', 'FLAT', 109 | '45_DOWN', 'SINGLE_DOWN', 'DOUBLE_DOWN', 'NOT_COMPUTABLE', 110 | 'OUT_OF_RANGE'] 111 | 112 | SPECIAL_GLUCOSE_VALUES = {0: None, 113 | 1: 'SENSOR_NOT_ACTIVE', 114 | 2: 'MINIMAL_DEVIATION', 115 | 3: 'NO_ANTENNA', 116 | 5: 'SENSOR_NOT_CALIBRATED', 117 | 6: 'COUNTS_DEVIATION', 118 | 9: 'ABSOLUTE_DEVIATION', 119 | 10: 'POWER_DEVIATION', 120 | 12: 'BAD_RF'} 121 | 122 | 123 | LANGUAGES = { 124 | 0: None, 125 | 1033: 'ENGLISH', 126 | } 127 | 128 | 129 | -------------------------------------------------------------------------------- /usbreceiver/crc16.py: -------------------------------------------------------------------------------- 1 | ######################################################################### 2 | # This source file is from the openaps/dexcom_reader project. 3 | # 4 | # https://github.com/openaps/dexcom_reader 5 | # 6 | # It is under an MIT licence described in the 3 paragraphs below: 7 | # 8 | ######################################################################### 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a 11 | # copy of this software and associated documentation files (the "Software"), 12 | # to deal in the Software without restriction, including without limitation 13 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | # and/or sell copies of the Software, and to permit persons to whom the 15 | # Software is furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included 18 | # in all copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 21 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 23 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 24 | # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 25 | # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 26 | # OTHER DEALINGS IN THE SOFTWARE. 27 | # 28 | ######################################################################### 29 | 30 | TABLE = [ 31 | 0, 4129, 8258, 12387, 16516, 20645, 24774, 28903, 33032, 37161, 41290, 32 | 45419, 49548, 53677, 57806, 61935, 4657, 528, 12915, 8786, 21173, 17044, 33 | 29431, 25302, 37689, 33560, 45947, 41818, 54205, 50076, 62463, 58334, 9314, 34 | 13379, 1056, 5121, 25830, 29895, 17572, 21637, 42346, 46411, 34088, 38153, 35 | 58862, 62927, 50604, 54669, 13907, 9842, 5649, 1584, 30423, 26358, 22165, 36 | 18100, 46939, 42874, 38681, 34616, 63455, 59390, 55197, 51132, 18628, 22757, 37 | 26758, 30887, 2112, 6241, 10242, 14371, 51660, 55789, 59790, 63919, 35144, 38 | 39273, 43274, 47403, 23285, 19156, 31415, 27286, 6769, 2640, 14899, 10770, 39 | 56317, 52188, 64447, 60318, 39801, 35672, 47931, 43802, 27814, 31879, 19684, 40 | 23749, 11298, 15363, 3168, 7233, 60846, 64911, 52716, 56781, 44330, 48395, 41 | 36200, 40265, 32407, 28342, 24277, 20212, 15891, 11826, 7761, 3696, 65439, 42 | 61374, 57309, 53244, 48923, 44858, 40793, 36728, 37256, 33193, 45514, 41451, 43 | 53516, 49453, 61774, 57711, 4224, 161, 12482, 8419, 20484, 16421, 28742, 44 | 24679, 33721, 37784, 41979, 46042, 49981, 54044, 58239, 62302, 689, 4752, 45 | 8947, 13010, 16949, 21012, 25207, 29270, 46570, 42443, 38312, 34185, 62830, 46 | 58703, 54572, 50445, 13538, 9411, 5280, 1153, 29798, 25671, 21540, 17413, 47 | 42971, 47098, 34713, 38840, 59231, 63358, 50973, 55100, 9939, 14066, 1681, 48 | 5808, 26199, 30326, 17941, 22068, 55628, 51565, 63758, 59695, 39368, 35305, 49 | 47498, 43435, 22596, 18533, 30726, 26663, 6336, 2273, 14466, 10403, 52093, 50 | 56156, 60223, 64286, 35833, 39896, 43963, 48026, 19061, 23124, 27191, 31254, 51 | 2801, 6864, 10931, 14994, 64814, 60687, 56684, 52557, 48554, 44427, 40424, 52 | 36297, 31782, 27655, 23652, 19525, 15522, 11395, 7392, 3265, 61215, 65342, 53 | 53085, 57212, 44955, 49082, 36825, 40952, 28183, 32310, 20053, 24180, 11923, 54 | 16050, 3793, 7920 55 | ] 56 | 57 | 58 | def crc16(buf, start=None, end=None): 59 | if start is None: 60 | start = 0 61 | if end is None: 62 | end = len(buf) 63 | num = 0 64 | for i in range(start, end): 65 | num = ((num<<8)&0xff00) ^ TABLE[((num>>8)&0xff)^buf[i]] 66 | return num & 0xffff 67 | -------------------------------------------------------------------------------- /usbreceiver/database_records.py: -------------------------------------------------------------------------------- 1 | ######################################################################### 2 | # This source file is from the openaps/dexcom_reader project. 3 | # 4 | # https://github.com/openaps/dexcom_reader 5 | # 6 | # It is under an MIT licence described in the 3 paragraphs below: 7 | # 8 | ######################################################################### 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a 11 | # copy of this software and associated documentation files (the "Software"), 12 | # to deal in the Software without restriction, including without limitation 13 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | # and/or sell copies of the Software, and to permit persons to whom the 15 | # Software is furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included 18 | # in all copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 21 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 23 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 24 | # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 25 | # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 26 | # OTHER DEALINGS IN THE SOFTWARE. 27 | # 28 | ######################################################################### 29 | # 30 | # The original file has been modified to dump data in a less annoying 31 | # format in BaseDatabaseRecord. Methods system_secs() and display_secs() 32 | # have been added to GenericTimestampedRecord. This is because I needed 33 | # to store the extracted data in numeric format in a database file. 34 | # The existing methods system_time() and display_time() return 35 | # datetime structures which would have consumed more space in the 36 | # database. In several methods, the FORMAT strings have been modified 37 | # to replace 'c' (char) with 'B' (byte). This makes it easier to 38 | # work with the extracted data. There's no longer any need to 39 | # call ord() every time the value is referenced. 40 | # New class, G5UserSettings & G6UserSettings have been added. These 41 | # allows one to read the user configuration settings. 42 | # 43 | ######################################################################### 44 | from msgpack.fallback import xrange 45 | 46 | import usbreceiver.crc16 47 | import usbreceiver.constants 48 | import struct 49 | import usbreceiver.util 50 | import binascii 51 | 52 | from usbreceiver import constants, util 53 | 54 | EGV_TESTNUM_MASK = 0x00ffffff 55 | 56 | class BaseDatabaseRecord(object): 57 | FORMAT = None 58 | 59 | @classmethod 60 | def _CheckFormat(cls): 61 | if cls.FORMAT is None or not cls.FORMAT: 62 | raise NotImplementedError("Subclasses of %s need to define FORMAT" 63 | % cls.__name__) 64 | 65 | @classmethod 66 | def _ClassFormat(cls): 67 | cls._CheckFormat() 68 | return struct.Struct(cls.FORMAT) 69 | 70 | @classmethod 71 | def _ClassSize(cls): 72 | return cls._ClassFormat().size 73 | 74 | @property 75 | def FMT(self): 76 | self._CheckFormat() 77 | return self._ClassFormat() 78 | 79 | @property 80 | def SIZE(self): 81 | return self._ClassSize() 82 | 83 | @property 84 | def crc(self): 85 | return self.data[-1] 86 | 87 | def __init__(self, data, raw_data): 88 | self.raw_data = raw_data 89 | self.data = data 90 | self.check_crc() 91 | 92 | def check_crc(self): 93 | local_crc = self.calculate_crc() 94 | if local_crc != self.crc: 95 | raise constants.CrcError('Could not parse %s' % self.__class__.__name__) 96 | 97 | def dump(self): 98 | return ''.join(' %02x' % ord(c) for c in self.raw_data) 99 | 100 | def calculate_crc(self): 101 | return usbreceiver.crc16.crc16(self.raw_data[:-2]) 102 | 103 | @classmethod 104 | def Create(cls, data, record_counter): 105 | offset = record_counter * cls._ClassSize() 106 | raw_data = data[offset:offset + cls._ClassSize()] 107 | unpacked_data = cls._ClassFormat().unpack(raw_data) 108 | return cls(unpacked_data, raw_data) 109 | 110 | 111 | class GenericTimestampedRecord(BaseDatabaseRecord): 112 | FIELDS = [ ] 113 | BASE_FIELDS = [ 'system_time', 'display_time' ] 114 | 115 | @property 116 | def system_time(self): 117 | return util.ReceiverTimeToTime(self.data[0]) 118 | 119 | @property 120 | def display_time(self): 121 | return util.ReceiverTimeToTime(self.data[1]) 122 | 123 | @property 124 | def system_secs(self): 125 | return self.data[0] 126 | 127 | @property 128 | def display_secs(self): 129 | return self.data[1] 130 | 131 | def to_dict (self): 132 | d = dict( ) 133 | for k in self.BASE_FIELDS + self.FIELDS: 134 | d[k] = getattr(self, k) 135 | if callable(getattr(d[k], 'isoformat', None)): 136 | d[k] = d[k].isoformat( ) 137 | return d 138 | 139 | class GenericXMLRecord(GenericTimestampedRecord): 140 | FORMAT = '' helps make 240 | # a subsequent serial port access work. 241 | stat_info = os.stat(self._port_name) 242 | time.sleep(15) 243 | self._port = serial.Serial(port=self._port_name, baudrate=115200) 244 | 245 | except serial.SerialException: 246 | print('Read/Write permissions missing for', self._port_name) 247 | if sys.platform == "linux" or sys.platform == "linux2" or sys.platform == "darwin": 248 | stat_info = os.stat(self._port_name) 249 | port_gid = stat_info.st_gid 250 | port_group = grp.getgrgid(port_gid)[0] 251 | username = pwd.getpwuid(os.getuid())[0] 252 | # print '\nFor a persistent solution (recommended), run ...' 253 | # if sys.platform == "darwin": 254 | # print '\n sudo dseditgroup -o edit -a', username, '-t user', port_group 255 | # else: 256 | # # On Mint, Ubuntu, etc. 257 | # print '\n sudo addgroup', username, port_group 258 | # print '\n sudo -', username 259 | # print '\n OR' 260 | # # On Fedora, Red Hat, etc. 261 | # print '\n sudo usermod -a -G', port_group, username 262 | # print '\n su -', username 263 | # print '\nFor a short term solution, run ...' 264 | # print '\n sudo chmod 666', self._port_name,'\n' 265 | if self._port is not None: 266 | try: 267 | self.clear() 268 | except Exception as e: 269 | pass 270 | 271 | try: 272 | self.flush() 273 | except Exception as e: 274 | pass 275 | 276 | def Disconnect(self): 277 | if self._port is not None: 278 | # If the user disconnects the USB cable while in the middle 279 | # of a Write/Read operation, we can end up with junk in the 280 | # serial port buffers. After reconnecting the cable, this 281 | # junk can cause a lock-up on that port. So, clear and 282 | # flush the port during this Disconnect operation to prevent 283 | # a possible future lock-up. Note: the clear() and flush() 284 | # operations can throw exceptions when there is nothing to 285 | # be cleaned up, so we use try ... except to ignore those. 286 | try: 287 | self.clear() 288 | except Exception as e: 289 | #print 'Disconnect() : Exception =', e 290 | pass 291 | 292 | try: 293 | self.flush() 294 | except Exception as e: 295 | #print 'Disconnect() : Exception =', e 296 | pass 297 | self._port.close() 298 | self._port = None 299 | 300 | @property 301 | def port(self): 302 | if self._port is None: 303 | self.Connect() 304 | return self._port 305 | 306 | def write(self, *args, **kwargs): 307 | return self.port.write(*args, **kwargs) 308 | 309 | def read(self, *args, **kwargs): 310 | return self.port.read(*args, **kwargs) 311 | 312 | def readpacket(self, timeout=None): 313 | total_read = 4 314 | initial_read = self.read(total_read) 315 | all_data = initial_read 316 | if initial_read[0] == 1: 317 | command = initial_read[3] 318 | data_number = struct.unpack(' 6: 320 | toread = abs(data_number-6) 321 | second_read = self.read(toread) 322 | all_data += second_read 323 | total_read += toread 324 | out = second_read 325 | else: 326 | out = '' 327 | suffix = self.read(2) 328 | sent_crc = struct.unpack(' 1590: 347 | raise constants.Error('Invalid packet length') 348 | self.flush() 349 | self.write(packet) 350 | 351 | def WriteCommand(self, command_id, *args, **kwargs): 352 | p = packetwriter.PacketWriter() 353 | p.ComposePacket(command_id, *args, **kwargs) 354 | self.WritePacket(p.get_packet_bytes()) 355 | 356 | def GenericReadCommand(self, command_id): 357 | self.WriteCommand(command_id) 358 | return self.readpacket() 359 | 360 | def ReadTransmitterId(self): 361 | return self.GenericReadCommand(constants.READ_TRANSMITTER_ID).data 362 | 363 | def ReadLanguage(self): 364 | lang = self.GenericReadCommand(constants.READ_LANGUAGE).data 365 | return constants.LANGUAGES[struct.unpack('H', lang)[0]] 366 | 367 | def ReadBatteryLevel(self): 368 | level = self.GenericReadCommand(constants.READ_BATTERY_LEVEL).data 369 | return struct.unpack('I', level)[0] 370 | 371 | def ReadBatteryState(self): 372 | state = self.GenericReadCommand(constants.READ_BATTERY_STATE).data 373 | return constants.BATTERY_STATES[ord(state)] 374 | 375 | def ReadRTC(self): 376 | rtc = self.GenericReadCommand(constants.READ_RTC).data 377 | return util.ReceiverTimeToTime(struct.unpack('I', rtc)[0]) 378 | 379 | def ReadSystemTime(self): 380 | rtc = self.GenericReadCommand(constants.READ_SYSTEM_TIME).data 381 | return util.ReceiverTimeToTime(struct.unpack('I', rtc)[0]) 382 | 383 | def ReadSystemTimeOffset(self): 384 | raw = self.GenericReadCommand(constants.READ_SYSTEM_TIME_OFFSET).data 385 | return datetime.timedelta(seconds=struct.unpack('i', raw)[0]) 386 | 387 | def ReadDisplayTimeOffset(self): 388 | raw = self.GenericReadCommand(constants.READ_DISPLAY_TIME_OFFSET).data 389 | return datetime.timedelta(seconds=struct.unpack('i', raw)[0]) 390 | 391 | def WriteDisplayTimeOffset(self, offset=None): 392 | payload = struct.pack('i', offset) 393 | self.WriteCommand(constants.WRITE_DISPLAY_TIME_OFFSET, payload) 394 | packet = self.readpacket() 395 | return dict(ACK=ord(packet.command) == constants.ACK) 396 | 397 | 398 | def ReadDisplayTime(self): 399 | return self.ReadSystemTime() + self.ReadDisplayTimeOffset() 400 | 401 | def ReadGlucoseUnit(self): 402 | UNIT_TYPE = (None, 'mg/dL', 'mmol/L') 403 | gu = self.GenericReadCommand(constants.READ_GLUCOSE_UNIT).data 404 | return UNIT_TYPE[ord(gu[0])] 405 | 406 | def ReadClockMode(self): 407 | CLOCK_MODE = (24, 12) 408 | cm = self.GenericReadCommand(constants.READ_CLOCK_MODE).data 409 | return CLOCK_MODE[ord(cm[0])] 410 | 411 | def ReadDeviceMode(self): 412 | # ??? 413 | return self.GenericReadCommand(constants.READ_DEVICE_MODE).data 414 | 415 | def ReadBlindedMode(self): 416 | MODES = { 0: False } 417 | raw = self.GenericReadCommand(constants.READ_BLINDED_MODE).data 418 | mode = MODES.get(bytearray(raw)[0], True) 419 | return mode 420 | 421 | def ReadHardwareBoardId(self): 422 | return self.GenericReadCommand(constants.READ_HARDWARE_BOARD_ID).data 423 | 424 | def ReadEnableSetupWizardFlag (self): 425 | # ??? 426 | return self.GenericReadCommand(constants.READ_ENABLE_SETUP_WIZARD_FLAG).data 427 | 428 | def ReadSetupWizardState (self): 429 | # ??? 430 | return self.GenericReadCommand(constants.READ_SETUP_WIZARD_STATE).data 431 | 432 | def WriteChargerCurrentSetting (self, status): 433 | MAP = ( 'Off', 'Power100mA', 'Power500mA', 'PowerMax', 'PowerSuspended' ) 434 | payload = str(bytearray([MAP.index(status)])) 435 | self.WriteCommand(constants.WRITE_CHARGER_CURRENT_SETTING, payload) 436 | packet = self.readpacket() 437 | raw = bytearray(packet.data) 438 | return dict(ACK=ord(packet.command) == constants.ACK, raw=list(raw)) 439 | 440 | def ReadChargerCurrentSetting (self): 441 | MAP = ( 'Off', 'Power100mA', 'Power500mA', 'PowerMax', 'PowerSuspended' ) 442 | raw = bytearray(self.GenericReadCommand(constants.READ_CHARGER_CURRENT_SETTING).data) 443 | return MAP[raw[0]] 444 | 445 | 446 | # ManufacturingParameters: SerialNumber, HardwarePartNumber, HardwareRevision, DateTimeCreated, HardwareId 447 | def ReadManufacturingData(self): 448 | data = self.ReadRecords('MANUFACTURING_DATA')[0].xmldata 449 | return ET.fromstring(data) 450 | 451 | def ReadAllManufacturingData(self): 452 | data = self.ReadRecords('MANUFACTURING_DATA')[0].xmldata 453 | return data 454 | 455 | def flush(self): 456 | self.port.flush() 457 | 458 | def clear(self): 459 | self.port.flushInput() 460 | self.port.flushOutput() 461 | 462 | def GetFirmwareHeader(self): 463 | i = self.GenericReadCommand(constants.READ_FIRMWARE_HEADER) 464 | return ET.fromstring(i.data) 465 | 466 | # FirmwareSettingsParameters: FirmwareImageId 467 | def GetFirmwareSettings(self): 468 | i = self.GenericReadCommand(constants.READ_FIRMWARE_SETTINGS) 469 | return ET.fromstring(i.data) 470 | 471 | def DataPartitions(self): 472 | i = self.GenericReadCommand(constants.READ_DATABASE_PARTITION_INFO) 473 | return ET.fromstring(i.data) 474 | 475 | def ReadDatabasePageRange(self, record_type): 476 | record_type_index = constants.RECORD_TYPES.index(record_type) 477 | self.WriteCommand(constants.READ_DATABASE_PAGE_RANGE, 478 | chr(record_type_index)) 479 | packet = self.readpacket() 480 | return struct.unpack('II', packet.data) 481 | 482 | def ReadDatabasePage(self, record_type, page): 483 | record_type_index = constants.RECORD_TYPES.index(record_type) 484 | self.WriteCommand(constants.READ_DATABASE_PAGES, 485 | (chr(record_type_index), struct.pack('I', page), chr(1))) 486 | packet = self.readpacket() 487 | assert packet.command == 1 488 | # first index (uint), numrec (uint), record_type (byte), revision (byte), 489 | # page# (uint), r1 (uint), r2 (uint), r3 (uint), ushort (Crc) 490 | header_format = '<2IcB4IH' 491 | header_data_len = struct.calcsize(header_format) 492 | header = struct.unpack_from(header_format, packet.data) 493 | header_crc = usbreceiver.crc16.crc16(packet.data[:header_data_len - 2]) 494 | assert header_crc == header[-1] 495 | assert ord(header[2]) == record_type_index 496 | assert header[4] == page 497 | packet_data = packet.data[header_data_len:] 498 | 499 | return self.ParsePage(header, packet_data) 500 | 501 | def GenericRecordYielder(self, header, data, record_type): 502 | for x in xrange(header[1]): 503 | yield record_type.Create(data, x) 504 | 505 | def ParsePage(self, header, data): 506 | record_type = constants.RECORD_TYPES[ord(header[2])] 507 | revision = int(header[3]) 508 | generic_parser_map = self.PARSER_MAP 509 | if revision < 2 and record_type == 'CAL_SET': 510 | generic_parser_map.update(CAL_SET=database_records.LegacyCalibration) 511 | xml_parsed = ['PC_SOFTWARE_PARAMETER', 'MANUFACTURING_DATA'] 512 | if record_type in generic_parser_map: 513 | return self.GenericRecordYielder(header, data, 514 | generic_parser_map[record_type]) 515 | elif record_type in xml_parsed: 516 | return [database_records.GenericXMLRecord.Create(data, 0)] 517 | else: 518 | raise NotImplementedError('Parsing of %s has not yet been implemented' 519 | % record_type) 520 | 521 | def GetLastRecords(self, record_type): 522 | assert record_type in constants.RECORD_TYPES 523 | page_range = self.ReadDatabasePageRange(record_type) 524 | start, end = page_range 525 | records = list(self.ReadDatabasePage(record_type, end)) 526 | records.reverse( ) 527 | return records 528 | 529 | def iter_records (self, record_type): 530 | assert record_type in constants.RECORD_TYPES 531 | page_range = self.ReadDatabasePageRange(record_type) 532 | start, end = page_range 533 | if start != end or not end: 534 | end += 1 535 | for x in reversed(xrange(start, end)): 536 | records = list(self.ReadDatabasePage(record_type, x)) 537 | records.reverse( ) 538 | for record in records: 539 | yield record 540 | 541 | def ReadRecords(self, record_type): 542 | records = [] 543 | assert record_type in constants.RECORD_TYPES 544 | page_range = self.ReadDatabasePageRange(record_type) 545 | start, end = page_range 546 | if start != end or not end: 547 | end += 1 548 | for x in range(start, end): 549 | records.extend(self.ReadDatabasePage(record_type, x)) 550 | return records -------------------------------------------------------------------------------- /usbreceiver/util.py: -------------------------------------------------------------------------------- 1 | ######################################################################### 2 | # This source file is from the openaps/dexcom_reader project. 3 | # 4 | # https://github.com/openaps/dexcom_reader 5 | # 6 | # It is under an MIT licence described in the 3 paragraphs below: 7 | # 8 | ######################################################################### 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a 11 | # copy of this software and associated documentation files (the "Software"), 12 | # to deal in the Software without restriction, including without limitation 13 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | # and/or sell copies of the Software, and to permit persons to whom the 15 | # Software is furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included 18 | # in all copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 21 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 23 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 24 | # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 25 | # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 26 | # OTHER DEALINGS IN THE SOFTWARE. 27 | # 28 | ######################################################################### 29 | 30 | import usbreceiver.constants 31 | import datetime 32 | import os 33 | import platform 34 | import plistlib 35 | import re 36 | import subprocess 37 | import sys 38 | 39 | from usbreceiver import constants 40 | 41 | if sys.platform == 'win32': 42 | import serial.tools.list_ports 43 | #from _winreg import * 44 | 45 | 46 | def ReceiverTimeToTime(rtime): 47 | return constants.DEXCOM_EPOCH + rtime 48 | 49 | 50 | def linux_find_usbserial(vendor, product): 51 | DEV_REGEX = re.compile('^tty(USB|ACM)[0-9]+$') 52 | for usb_dev_root in os.listdir('/sys/bus/usb/devices'): 53 | device_name = os.path.join('/sys/bus/usb/devices', usb_dev_root) 54 | if not os.path.exists(os.path.join(device_name, 'idVendor')): 55 | continue 56 | idv = open(os.path.join(device_name, 'idVendor')).read().strip() 57 | if idv != vendor: 58 | continue 59 | idp = open(os.path.join(device_name, 'idProduct')).read().strip() 60 | if idp != product: 61 | continue 62 | for root, dirs, files in os.walk(device_name): 63 | for option in dirs + files: 64 | if DEV_REGEX.match(option): 65 | return os.path.join('/dev', option) 66 | 67 | 68 | def osx_find_usbserial(vendor, product): 69 | def recur(v): 70 | if hasattr(v, '__iter__') and 'idVendor' in v and 'idProduct' in v: 71 | if v['idVendor'] == vendor and v['idProduct'] == product: 72 | tmp = v 73 | while True: 74 | if 'IODialinDevice' not in tmp and 'IORegistryEntryChildren' in tmp: 75 | tmp = tmp['IORegistryEntryChildren'] 76 | elif 'IODialinDevice' in tmp: 77 | return tmp['IODialinDevice'] 78 | else: 79 | break 80 | 81 | if type(v) == list: 82 | for x in v: 83 | out = recur(x) 84 | if out is not None: 85 | return out 86 | elif type(v) == dict or issubclass(type(v), dict): 87 | for x in v.values(): 88 | out = recur(x) 89 | if out is not None: 90 | return out 91 | 92 | sp = subprocess.Popen(['/usr/sbin/ioreg', '-k', 'IODialinDevice', 93 | '-r', '-t', '-l', '-a', '-x'], 94 | stdout=subprocess.PIPE, 95 | stdin=subprocess.PIPE, stderr=subprocess.PIPE) 96 | stdout, _ = sp.communicate() 97 | plist = plistlib.readPlistFromString(stdout.decode()) 98 | return recur(plist) 99 | 100 | 101 | def thisIsWine(): 102 | # if sys.platform == 'win32': 103 | # try: 104 | # registry = ConnectRegistry(None, HKEY_LOCAL_MACHINE) 105 | # if registry is not None: 106 | # try: 107 | # winekey = OpenKey(registry, 'Software\Wine') 108 | # if winekey is not None: 109 | # return True 110 | # else: 111 | # return False 112 | # except Exception as e: 113 | # #print 'OpenKey failed. Exception =', e 114 | # return False 115 | # else: 116 | # return False 117 | # 118 | # except Exception as f: 119 | # #print 'ConnectRegistry failed. Exception =', f 120 | # return False 121 | # else: 122 | return False 123 | 124 | 125 | def win_find_usbserial(vendor, product): 126 | if thisIsWine(): 127 | # When running under WINE, we have no access to real USB information, such 128 | # as the Vendor & Product ID values. Also, serial.tools.list_ports.comports() 129 | # returns nothing. The real port under Linux (or OSX?) is mapped to a windows 130 | # serial port at \dosdevices\COMxx, but we don't know which one. Normally, 131 | # COM1 - COM32 are automatically mapped to /dev/ttyS0 - /dev/ttyS31. 132 | # If the Dexcom device is plugged in, it will be mapped to COM33 or greater. 133 | # We have no way of identifying which port >= COM33 is the right one, so 134 | # we'll just guess the first available one. 135 | return "\\\\.\\com33" 136 | else: 137 | for cport in serial.tools.list_ports.comports(): 138 | if (cport.vid == vendor) and (cport.pid == product): 139 | # found a port which matches vendor and product IDs 140 | if cport.device is not None: 141 | return cport.device 142 | return None 143 | 144 | 145 | 146 | def find_usbserial(vendor, product): 147 | """Find the tty device for a given usbserial devices identifiers. 148 | 149 | Args: 150 | vendor: (int) something like 0x0000 151 | product: (int) something like 0x0000 152 | 153 | Returns: 154 | String, like /dev/ttyACM0 or /dev/tty.usb... 155 | """ 156 | if platform.system() == 'Linux': 157 | vendor, product = [('%04x' % (x)).strip() for x in (vendor, product)] 158 | return linux_find_usbserial(vendor, product) 159 | elif platform.system() == 'Darwin': 160 | return osx_find_usbserial(vendor, product) 161 | elif platform.system() == 'Windows': 162 | return win_find_usbserial(vendor, product) 163 | else: 164 | raise NotImplementedError('Cannot find serial ports on %s' 165 | % platform.system()) 166 | --------------------------------------------------------------------------------