diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed1a652 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.zeo +*.csv +*.plot +*~ +hex2binary +junkyard diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b045e3a --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +all: hex2binary anne-110126.zeo anne-110127.zeo anne-110128.zeo anne-110129.zeo anne-110130.zeo anne-110131.zeo anne-110201.zeo + +%.zeo: hex/%.zeo + ./hex2binary <$^ >$@ + +sync: + rsync -av john-2.local:/Users/anne/education/bodytrack/zeologger/raw-data/\*.zeo . + rm *.csv + -./raw2csv.py + +plot: + ./plot_hypnogram.pl hypnogram.csv + +hex2binary: hex2binary.c + gcc -Wall hex2binary.c -o hex2binary diff --git a/TODO b/TODO new file mode 100644 index 0000000..0793bd9 --- /dev/null +++ b/TODO @@ -0,0 +1,4 @@ +Git setup +Figure out how to remotely cat +Commandline arg to select file +Commandline arg to block until new data, optionally diff --git a/ZeoRawData-2.0/PKG-INFO b/ZeoRawData-2.0/PKG-INFO new file mode 100644 index 0000000..63efbf3 --- /dev/null +++ b/ZeoRawData-2.0/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 1.0 +Name: ZeoRawData +Version: 2.0 +Summary: Zeo Raw Data Library +Home-page: http://www.myzeo.com +Author: Zeo Inc. +Author-email: customersupport@myZeo.com +License: http://developers.myzeo.com/terms-and-conditions/ +Description: Library allowing real-time interaction with a Zeo running the 2.6.3R firmware. +Platform: UNKNOWN diff --git a/ZeoRawData-2.0/README.txt b/ZeoRawData-2.0/README.txt new file mode 100644 index 0000000..c00c2e0 --- /dev/null +++ b/ZeoRawData-2.0/README.txt @@ -0,0 +1,15 @@ +Zeo Raw Data Library + +==================== + +Copyright 2010 by Zeo Inc. All Rights Reserved + + + +By using this library you agree to the terms and conditions located at: + http://developers.myzeo.com/terms-and-conditions/ + + + +Installation: + Run python setup.py install diff --git a/ZeoRawData-2.0/ZeoRawData/BaseLink.py b/ZeoRawData-2.0/ZeoRawData/BaseLink.py new file mode 100644 index 0000000..dbb65f0 --- /dev/null +++ b/ZeoRawData-2.0/ZeoRawData/BaseLink.py @@ -0,0 +1,185 @@ +""" +BaseLink +-------- + +Listens to the data coming from the serial port connected to Zeo. + +The serial port is set at baud 38400, no parity, one stop bit. +Data is sent Least Significant Byte first. + +The serial protocol is: + * AncllLLTttsid + + * A is a character starting the message + * n is the protocol "version", ie "4" + * c is a one byte checksum formed by summing the identifier byte and all the data bytes + * ll is a two byte message length sent LSB first. This length includes the size of the data block plus the identifier. + * LL is the inverse of ll sent for redundancy. If ll does not match ~LL, we can start looking for the start of the next block immediately, instead of reading some arbitrary number of bytes, based on a bad length. + * T is the lower 8 bits of Zeo's unix time. + * tt is the 16-bit sub-second (runs through 0xFFFF in 1second), LSB first. + * s is an 8-bit sequence number. + * i is the datatype + * d is the array of binary data + +The incoming data is cleaned up into packets containing a timestamp, +the raw data output version, and the associated data. + +External code can be sent new data as it arrives by adding +themselves to the callback list using the addCallBack function. +It is suggested, however, that external code use the ZeoParser to +organize the data into events and slices of data. + +""" + +#System Libraries +import threading +#from serial import Serial + +#Zeo Libraries +from Utility import * + +class BaseLink(threading.Thread): + """ + Runs on a seperate thread and handles the raw serial communications + and parsing required to communicate with Zeo. Each time data is + successfully received, it is sent out to all of the callbacks. + """ + + def __init__(self, port): + """ + Create a new class with default serial port settings. + Parameters: + port - the name of the serialport to open + i.e. COM1 or /dev/ttyUSB0 + """ + + threading.Thread.__init__(self) + self.setDaemon(True) + + self.terminateEvent = threading.Event() + + self.callbacks = [] + + self.ser = port + + def addCallback(self, callback): + """Add a callback that will be passed valid data.""" + self.callbacks.append(callback) + + def error(self, msg): + """Function for error printing.""" + print('Capture error: %s' % msg) + + def run(self): + """Begins thread execution and raw serial parsing.""" + + zeoTime = None + version = None + ring = ' ' + fatal_error_ring = ' ' + while not self.terminateEvent.isSet(): + if ring != 'A4': + c = self.ser.read(1) + if len(c) == 1: + ring = ring[1:] + c + fatal_error_ring = fatal_error_ring[1:] + c + if fatal_error_ring == 'FATAL_ERROR_': + print 'A fatal error has occured.' + self.terminateEvent.set() + else: + self.error('Read timeout in sync.') + ring = ' ' + self.ser.flushInput() + else: + + ring = ' ' + + raw = self.ser.read(1) + if len(raw) != 1: + self.error('Failed to read checksum.') + continue + + cksum = ord(raw) + + raw = self.ser.read(2) + if len(raw) != 2: + self.error('Failed to read length.') + continue + + datalen = getUInt16(raw) + + raw = self.ser.read(2) + if len(raw) != 2: + self.error('Failed to read second length.') + continue + + datalen2 = getUInt16(raw) ^ 0xffff + + if datalen != datalen2: + self.error('Mismatched lengths.') + continue + + raw = self.ser.read(3) + if len(raw) != 3: + self.error('Failed to read timestamp.') + continue + timestamp_lowbyte = ord(raw[0]) + timestamp_subsec = getUInt16(raw[1:3])/65535.0 + timestamp = 0 + + raw = self.ser.read(1) + if len(raw) != 1: + self.error('Failed to read sequence number.') + continue + + seqnum = ord(raw) + + raw = self.ser.read(datalen) + if len(raw) != datalen: + self.error('Failed to read data.') + continue + + data = raw + + if sum(map(ord, data)) % 256 != cksum: + self.error('bad checksum') + continue + + + # Check the datatype is supported + if dataTypes.keys().count(ord(data[0])) == 0: + self.error('Bad datatype 0x%02X.' % ord(data[0])) + continue + + datatype = dataTypes[ord(data[0])] + + # Check if this packet is an RTC time or version number + if datatype == 'ZeoTimestamp': + zeoTime = getUInt32(data[1:]) + continue + elif datatype == 'Version': + version = getUInt32(data[1:]) + continue + + # Don't pass the timestamp or version data since we send that + # information along with the other data + if zeoTime == None or version == None: + continue + + # Construct the full timestamp from the most recently received RTC + # value in seconds, and the lower 8 bits of the RTC value as of + # when this object was sent. + if (zeoTime & 0xff == timestamp_lowbyte): + timestamp = zeoTime + elif ((zeoTime - 1) & 0xff == timestamp_lowbyte): + timestamp = zeoTime - 1 + elif ((zeoTime + 1) & 0xff == timestamp_lowbyte): + timestamp = zeoTime + 1 + else: + # Something doesn't line up. Maybe unit was reset. + timestamp = zeoTime + + for callback in self.callbacks: + callback(timestamp, timestamp_subsec, version, data) + self.ser.close() + diff --git a/ZeoRawData-2.0/ZeoRawData/BaseLink.pyc b/ZeoRawData-2.0/ZeoRawData/BaseLink.pyc new file mode 100644 index 0000000..eb211ee Binary files /dev/null and b/ZeoRawData-2.0/ZeoRawData/BaseLink.pyc differ diff --git a/ZeoRawData-2.0/ZeoRawData/BaseLink.py~ b/ZeoRawData-2.0/ZeoRawData/BaseLink.py~ new file mode 100644 index 0000000..e5426e0 --- /dev/null +++ b/ZeoRawData-2.0/ZeoRawData/BaseLink.py~ @@ -0,0 +1,184 @@ +""" +BaseLink +-------- + +Listens to the data coming from the serial port connected to Zeo. + +The serial port is set at baud 38400, no parity, one stop bit. +Data is sent Least Significant Byte first. + +The serial protocol is: + * AncllLLTttsid + + * A is a character starting the message + * n is the protocol "version", ie "4" + * c is a one byte checksum formed by summing the identifier byte and all the data bytes + * ll is a two byte message length sent LSB first. This length includes the size of the data block plus the identifier. + * LL is the inverse of ll sent for redundancy. If ll does not match ~LL, we can start looking for the start of the next block immediately, instead of reading some arbitrary number of bytes, based on a bad length. + * T is the lower 8 bits of Zeo's unix time. + * tt is the 16-bit sub-second (runs through 0xFFFF in 1second), LSB first. + * s is an 8-bit sequence number. + * i is the datatype + * d is the array of binary data + +The incoming data is cleaned up into packets containing a timestamp, +the raw data output version, and the associated data. + +External code can be sent new data as it arrives by adding +themselves to the callback list using the addCallBack function. +It is suggested, however, that external code use the ZeoParser to +organize the data into events and slices of data. + +""" + +#System Libraries +import threading +from serial import Serial + +#Zeo Libraries +from Utility import * + +class BaseLink(threading.Thread): + """ + Runs on a seperate thread and handles the raw serial communications + and parsing required to communicate with Zeo. Each time data is + successfully received, it is sent out to all of the callbacks. + """ + + def __init__(self, port): + """ + Create a new class with default serial port settings. + Parameters: + port - the name of the serialport to open + i.e. COM1 or /dev/ttyUSB0 + """ + threading.Thread.__init__(self) + self.setDaemon(True) + + self.terminateEvent = threading.Event() + + self.callbacks = [] + + self.ser = Serial(port, 38400, timeout = 5) + self.ser.flushInput() + + def addCallback(self, callback): + """Add a callback that will be passed valid data.""" + self.callbacks.append(callback) + + def error(self, msg): + """Function for error printing.""" + print('Capture error: %s' % msg) + + def run(self): + """Begins thread execution and raw serial parsing.""" + zeoTime = None + version = None + ring = ' ' + fatal_error_ring = ' ' + while not self.terminateEvent.isSet(): + if ring != 'A4': + c = self.ser.read(1) + if len(c) == 1: + ring = ring[1:] + c + fatal_error_ring = fatal_error_ring[1:] + c + if fatal_error_ring == 'FATAL_ERROR_': + print 'A fatal error has occured.' + self.terminateEvent.set() + else: + self.error('Read timeout in sync.') + ring = ' ' + self.ser.flushInput() + else: + + ring = ' ' + + raw = self.ser.read(1) + if len(raw) != 1: + self.error('Failed to read checksum.') + continue + + cksum = ord(raw) + + raw = self.ser.read(2) + if len(raw) != 2: + self.error('Failed to read length.') + continue + + datalen = getUInt16(raw) + + raw = self.ser.read(2) + if len(raw) != 2: + self.error('Failed to read second length.') + continue + + datalen2 = getUInt16(raw) ^ 0xffff + + if datalen != datalen2: + self.error('Mismatched lengths.') + continue + + raw = self.ser.read(3) + if len(raw) != 3: + self.error('Failed to read timestamp.') + continue + timestamp_lowbyte = ord(raw[0]) + timestamp_subsec = getUInt16(raw[1:3])/65535.0 + timestamp = 0 + + raw = self.ser.read(1) + if len(raw) != 1: + self.error('Failed to read sequence number.') + continue + + seqnum = ord(raw) + + raw = self.ser.read(datalen) + if len(raw) != datalen: + self.error('Failed to read data.') + continue + + data = raw + + if sum(map(ord, data)) % 256 != cksum: + self.error('bad checksum') + continue + + + # Check the datatype is supported + if dataTypes.keys().count(ord(data[0])) == 0: + self.error('Bad datatype 0x%02X.' % ord(data[0])) + continue + + datatype = dataTypes[ord(data[0])] + + # Check if this packet is an RTC time or version number + if datatype == 'ZeoTimestamp': + zeoTime = getUInt32(data[1:]) + continue + elif datatype == 'Version': + version = getUInt32(data[1:]) + continue + + # Don't pass the timestamp or version data since we send that + # information along with the other data + if zeoTime == None or version == None: + continue + + # Construct the full timestamp from the most recently received RTC + # value in seconds, and the lower 8 bits of the RTC value as of + # when this object was sent. + if (zeoTime & 0xff == timestamp_lowbyte): + timestamp = zeoTime + elif ((zeoTime - 1) & 0xff == timestamp_lowbyte): + timestamp = zeoTime - 1 + elif ((zeoTime + 1) & 0xff == timestamp_lowbyte): + timestamp = zeoTime + 1 + else: + # Something doesn't line up. Maybe unit was reset. + timestamp = zeoTime + + for callback in self.callbacks: + callback(timestamp, timestamp_subsec, version, data) + self.ser.close() + diff --git a/ZeoRawData-2.0/ZeoRawData/Parser.py b/ZeoRawData-2.0/ZeoRawData/Parser.py new file mode 100644 index 0000000..3856ec5 --- /dev/null +++ b/ZeoRawData-2.0/ZeoRawData/Parser.py @@ -0,0 +1,118 @@ +""" +Parser +------ + +This module parses data from the BaseCapture module and assembles them +into slices that encompass a range of data representative of +Zeo's current status. + +There are two different callbacks. One for slice callbacks and one that +the module will pass events to. + +""" + +#System Libraries +from math import sqrt +import time + +#Zeo Libraries +from Utility import * + +class Parser: + """ + Interprets the incoming Zeo data and encapsulates it into an easy to use dictionary. + """ + def clearSlice(self): + """Resets the current Slice""" + self.Slice = {'ZeoTimestamp' : None, # String %m/%d/%Y %H:%M:%S + 'Version' : None, # Integer value + 'SQI' : None, # Integer value (0-30) + 'Impedance' : None, # Integer value as read by the ADC + # Unfortunately left raw/unitless due to + # nonlinearity in the readings. + 'Waveform' : [], # Array of signed ints + 'FrequencyBins' : {}, # Dictionary of frequency bins which are relative to the 2-30hz power + 'BadSignal' : None, # Boolean + 'SleepStage' : None # String + } + + def __init__(self): + """Creates a new parser object.""" + self.EventCallbacks = [] + self.SliceCallbacks = [] + self.WaveBuffer = [0]*128 + + self.clearSlice() + + def addEventCallback(self, callback): + """Add a function to call when an Event has occured.""" + self.EventCallbacks.append(callback) + + def addSliceCallback(self, callback): + """Add a function to call when a Slice of data is completed.""" + self.SliceCallbacks.append(callback) + + def update(self, timestamp, timestamp_subsec, version, data): + """ + Update the current Slice with new data from Zeo. + This function is setup to be easily added to the + BaseLink's callbacks. + """ + + if version != 3: + print 'Unsupport raw data output version: %i' % version + return + + datatype = dataTypes[ord(data[0])] + + if datatype == 'Event': + for callback in self.EventCallbacks: + callback(time.strftime('%m/%d/%Y %H:%M:%S', time.gmtime(timestamp)), + version, eventTypes[getUInt32(data[1:5])])#for some reason 5 long when did 1: + + elif datatype == 'SliceEnd': + self.Slice['ZeoTimestamp'] = time.strftime('%m/%d/%Y %H:%M:%S', time.gmtime(timestamp)) + self.Slice['Version'] = version + for callback in self.SliceCallbacks: + callback(self.Slice) + self.clearSlice() + + elif datatype == 'Waveform': + wave = [] + for i in range(1,256,2): + value = getInt16(data[i:i+2])# Raw value + value = float(value*315)/0x8000 # convert to uV value FIX + wave.append(value) + + filtered = filter60hz(self.WaveBuffer + wave) + + self.Slice[datatype] = filtered[90:218] # grab the valid middle as the current second + self.WaveBuffer = wave # store this second for processing the next second + + # NOTE: it is possible to mess this up easily for the first second. + # A second could be stored, headband docked, then undocked and it would + # use the old data as the previous second. This is considered ok since it + # will only be bad for the first portion of the first second of data. + + elif datatype == 'FrequencyBins': + for bin in range(7): + value = float(getUInt16(data[(bin*2+1):(bin*2+3)]))/0x8000 + self.Slice[datatype][frequencyBins[bin]] = value + + elif datatype == 'BadSignal': + self.Slice[datatype] = (getUInt32(data[1:])>0) + + elif datatype == 'SleepStage': + self.Slice[datatype] = sleepStages[getUInt32(data[1:])] + + elif datatype == 'Impedance': + impedance = getUInt32(data[1:]) + impi = (impedance & 0x0000FFFF) - 0x8000 # In Phase Component + impq = ((impedance & 0xFFFF0000) >> 16) - 0x8000 # Quadrature Component + if not impi == 0x7FFF: # 32767 indicates the impedance is bad + impSquared = (impi * impi) + (impq * impq) + self.Slice[datatype] = sqrt(impSquared) + + elif datatype == 'SQI': + self.Slice[datatype] = getUInt32(data[1:]) + diff --git a/ZeoRawData-2.0/ZeoRawData/Parser.pyc b/ZeoRawData-2.0/ZeoRawData/Parser.pyc new file mode 100644 index 0000000..a902539 Binary files /dev/null and b/ZeoRawData-2.0/ZeoRawData/Parser.pyc differ diff --git a/ZeoRawData-2.0/ZeoRawData/Utility.py b/ZeoRawData-2.0/ZeoRawData/Utility.py new file mode 100644 index 0000000..4d0d3ff --- /dev/null +++ b/ZeoRawData-2.0/ZeoRawData/Utility.py @@ -0,0 +1,175 @@ +""" +Utility +------- + +A collection of general purpose functions and datatypes. + +""" + +from struct import unpack + +def getInt32(A): + """Creates a signed 32bit integer from a 4 item array""" + return unpack(' + +int main(int argc, char **argv) +{ + char buf[1000]; + int i; + for (i=0; i<5; i++) fgets(buf, sizeof(buf), stdin); + while (1) { + int n; + if (1 != fscanf(stdin, "%x", &n)) break; + putchar(n); + } + return 0; +} diff --git a/plot_hypnogram.pl b/plot_hypnogram.pl new file mode 100755 index 0000000..9d1abea --- /dev/null +++ b/plot_hypnogram.pl @@ -0,0 +1,15 @@ +#!/usr/bin/perl -w +<>; +$time=0; + +open(PLOT, ">hypnogram.plot"); + +while (<>) { + chomp; + @f = split ','; + print PLOT "$time $f[5]\n"; + $time += .5; +} + +system 'echo "plot \'hypnogram.plot\'" | gnuplot -p -'; + diff --git a/raw2csv.py b/raw2csv.py new file mode 100755 index 0000000..1c89c9b --- /dev/null +++ b/raw2csv.py @@ -0,0 +1,201 @@ +#!/usr/bin/python -d +# -*- coding: utf-8 -*- +# DataRecorder.pyw +# +# Streams the real-time data coming from a Zeo unit into .csv files. +import sys +import time +import csv +import os + +# from serial import * + +from glob import glob + +sys.path.append("/Users/rsargent/projects/bodytrack/zeo/raw-data/raw2csv/ZeoRawData-2.0"); + +from ZeoRawData import BaseLink, Parser + +def scanPorts(): + portList = [] + + # Helps find USB>Serial converters on linux + for p in glob('/dev/ttyUSB*'): + portList.append(p) + #Linux and Windows + for i in range(256): + try: + ser = Serial(i) + if ser.isOpen(): + #Check that the port is actually valid + #Otherwise invalid /dev/ttyS* ports may be added + portList.append(ser.portstr) + ser.close() + except SerialException: + pass + return portList + + +class HexFileReader: + def __init__(self, filename): + self.file = open(filename, 'r') + # skip 5 lines + for i in range(5): + self.file.readline() + + def read(self, num): + ret = "" + for i in range(num): + ret += self.read1() + return ret + + def read1(self): + c = self.file.read(1) + if (c == ' '): + c = self.file.read(1) + c += self.file.read(1) + if (len(c) < 2): + print "DONE" + sys.exit(0) + return chr(int(c,16)) + + def flushInput(self): + pass + +class FileReader: + def __init__(self, filename): + self.file = open(filename, 'r') + # skip 5 lines + for i in range(5): + self.file.readline() + + def read(self, num): + return self.file.read(num) + + def flushInput(self): + pass + + +class ZeoToCSV: + def __init__(self, parent=None): + samplesFileName = 'raw_samples.csv' + sgramFileName = 'spectrogram.csv' + hgramFileName = 'hypnogram.csv' + eventsFileName = 'events.csv' + + self.hypToHeight = {'Undefined' : 0, + 'Deep' : 1, + 'Light' : 2, + 'REM' : 3, + 'Awake' : 4} + + # Only create headers when the files are being created for the first time. + # After that, all new data should be appended to the existing files. + samplesNeedHeader = False + sgramNeedHeader = False + hgramNeedHeader = False + eventsNeedHeader = False + + if not os.path.isfile(samplesFileName): + samplesNeedHeader = True + + if not os.path.isfile(sgramFileName): + sgramNeedHeader = True + + if not os.path.isfile(hgramFileName): + hgramNeedHeader = True + + if not os.path.isfile(eventsFileName): + eventsNeedHeader = True + + self.rawSamples = csv.writer(open(samplesFileName, 'a+b'), delimiter=',', + quotechar='"', quoting=csv.QUOTE_MINIMAL) + self.spectrogram = csv.writer(open(sgramFileName, 'a+b'), delimiter=',', + quotechar='"', quoting=csv.QUOTE_MINIMAL) + self.hypnogram = csv.writer(open(hgramFileName, 'a+b'), delimiter=',', + quotechar='"', quoting=csv.QUOTE_MINIMAL) + self.eventsOut = csv.writer(open(eventsFileName, 'a+b'), delimiter=',', + quotechar='"', quoting=csv.QUOTE_MINIMAL) + + if samplesNeedHeader: + self.rawSamples.writerow(["Time Stamp","Version","SQI","Impedance","Bad Signal (Y/N)","Voltage (uV)"]) + + if sgramNeedHeader: + self.spectrogram.writerow(["Time Stamp","Version","SQI","Impedance","Bad Signal (Y/N)", + "2-4 Hz","4-8 Hz","8-13 Hz","11-14 Hz","13-18 Hz","18-21 Hz","30-50 Hz"]) + + if hgramNeedHeader: + self.hypnogram.writerow(["Time Stamp","Version","SQI","Impedance","Bad Signal (Y/N)","State (0-4)","State (named)"]) + + if eventsNeedHeader: + self.eventsOut.writerow(["Time Stamp","Version","Event"]) + + def updateSlice(self, slice): + + timestamp = slice['ZeoTimestamp'] + ver = slice['Version'] + + if not slice['SQI'] == None: + sqi = str(slice['SQI']) + else: + sqi = '–' + + if not slice['Impedance'] == None: + imp = str(int(slice['Impedance'])) + else: + imp = '–' + if slice['BadSignal']: + badSignal = 'Y' + else: + badSignal = 'N' + if not slice['Waveform'] == []: + self.rawSamples.writerow([timestamp,ver,sqi,imp,badSignal] + slice['Waveform']) + if len(slice['FrequencyBins'].values()) == 7: + f = slice['FrequencyBins'] + bins = [f['2-4'],f['4-8'],f['8-13'],f['11-14'],f['13-18'],f['18-21'],f['30-50']] + self.spectrogram.writerow([timestamp,ver,sqi,imp,badSignal] + bins) + if not slice['SleepStage'] == None: + stage = slice['SleepStage'] + self.hypnogram.writerow([timestamp,ver,sqi,imp,badSignal] + + [self.hypToHeight[stage],str(stage)]) + + def updateEvent(self, timestamp, version, event): + self.eventsOut.writerow([timestamp,version,event]) + +if __name__ == '__main__': + # Find ports. + # TODO: offer a command line option for selecting ports. + +# ports = scanPorts() +# if len(ports) > 0 : +# print "Found the following ports:" +# for port in ports: +# print port +# print "" +# print "Using port "+ports[0] +# print "" +# portStr = ports[0] +# else: +# sys.exit("No serial ports found.") + + # Initialize + output = ZeoToCSV() + + reader = FileReader("anne-110202.zeo") + + link = BaseLink.BaseLink(reader) + + parser = Parser.Parser() + # Add callbacks + link.addCallback(parser.update) + parser.addEventCallback(output.updateEvent) + parser.addSliceCallback(output.updateSlice) + # Start Link + link.start() + + # TODO: perhaps use a more forgiving key? This would require polling the keyboard without blocking. + print "Hit ctrl-C at any time to stop." + while True: + time.sleep(5) + + sys.exit()