# quectel L76 GNSS library for Micropython
# pycom pytrack module for wipy, lopy, sipy ...
# Andre Peeters
# andre@andrethemac.be
# v4 2018-03-24
# v4b 2018-03-26 faster fix using GLL instead of GGA
# v5 2019-06-07 added pmtk commands
# v6 2020-03-20 added fix for newer chips with longer messages
# V4.10 Chips have longer RMC, GSA and GVS messages
# (Kudos to askpatrickw for finding the issue)
# RMC -> added NavigationaalStatus
# GSA -> added GNSSSystemID
# GSV -> added SignalID
# based upon the original L76GLNSS library
# and the modifications by neuromystix
# every lookup of coordinates or other GPS data has to wait for the
# right GPS message, no caching of GPS data
# MIT licence

from machine import Timer
import time
import gc
import binascii

# TODO: annotate sattelites in view


class L76GNSS:

    GPS_I2CADDR = const(0x10)
    NMEA410 = const(410)

    def __init__(self, pytrack=None, sda='P22', scl='P21', timeout=180, debug=False):
        if pytrack is not None:
            self.i2c = pytrack.i2c
        else:
            from machine import I2C
            self.i2c = I2C(0, mode=I2C.MASTER, pins=(sda, scl))

        self.chrono = Timer.Chrono()
        self.timeout = timeout
        self.timeout_status = True
        self.reg = bytearray(1)
        self.i2c.writeto(GPS_I2CADDR, self.reg)
        self.fix = False
        self.Latitude = None
        self.Longitude = None
        self.debug = debug
        self.timeLastFix = 0
        self.ttf = -1
        self.lastmessage = {}
        self.NMEAVersion = 301
        self.ChipVersionID = None
        self.release = 1.0
        self.ReleaseString = None
        self.BuildID = None
        self.ProductModel = None
        self.SDK = None
        self.get_dt_release(debug=False)
        self.get_chip_version(debug=False)


    def _read(self):
        """read the data stream form the gps"""
        # Changed from 64 to 128 - I2C L76 says it can read till 255 bytes
        reg = b""
        while len(set(reg)) < 10: # Throw away empty buffers
            reg = self.i2c.readfrom(GPS_I2CADDR, 255)
        return reg

    @staticmethod
    def _convert_coord(coord, orientation):
        """convert a ddmm.mmmm to dd.dddddd degrees"""
        coord = (float(coord) // 100) + ((float(coord) % 100) / 60)
        if orientation == 'S' or orientation == 'W':
            coord *= -1
        return coord

    def time_fixed(self):
        """how long till the last fix"""
        return int(time.ticks_ms()/1000) - self.timeLastFix

    def _mixhash(self, keywords, sentence):
        """return hash with keywords filled with sentence"""
        ret = {}
        while len(keywords) - len(sentence) > 0:
            sentence += ('',)
        if len(keywords) == len(sentence):
            # for k, s in zip(keywords, sentence):
            #     ret[k] = s
            ret = dict(zip(keywords, sentence))
            try:
                ret['Latitude'] = self._convert_coord(ret['Latitude'], ret['NS'])
            except:
                pass
            try:
                ret['Longitude'] = self._convert_coord(ret['Longitude'], ret['EW'])
            except:
                pass
            return ret
        else:
            return None

    def _GGA(self, sentence):
        """essentials fix and accuracy data"""
        keywords = ['NMEA', 'UTCTime', 'Latitude', 'NS', 'Longitude', 'EW',
                    'FixStatus', 'NumberOfSV', 'HDOP',
                    'Altitude', 'M', 'GeoIDSeparation', 'M', 'DGPSAge', 'DGPSStationID']
        return self._mixhash(keywords, sentence)

    def _GLL(self, sentence):
        """GLL sentence (geolocation)"""
        keywords = ['NMEA', 'Latitude', 'NS', 'Longitude', 'EW',
                    'UTCTime', 'dataValid', 'PositioningMode']
        return self._mixhash(keywords, sentence)

    def _RMC(self, sentence):
        """required minimum position data"""
        if len(sentence) == 11:
            sentence.append('N')
        keywords = ['NMEA', 'UTCTime', 'dataValid', 'Latitude', 'NS', 'Longitude', 'EW',
                    'Speed', 'COG', 'Date', '', '', 'PositioningMode']
        # if len(sentence) > len(keywords):
        if self.NMEAVersion >= NMEA410:
            keywords.append('NavigationaalStatus')
        return self._mixhash(keywords, sentence)

    def _VTG(self, sentence):
        """track and ground speed"""
        keywords = ['NMEA', 'COG-T', 'T', 'COG-M', 'M', 'SpeedKnots', 'N', 'SpeedKm', 'K',
                    'PositioningMode']
        return self._mixhash(keywords, sentence)

    def _GSA(self, sentence):
        """fix state, the sattelites used and DOP info"""
        keywords = ['NMEA', 'Mode', 'FixStatus',
                    'SatelliteUsed01', 'SatelliteUsed02', 'SatelliteUsed03',
                    'SatelliteUsed04', 'SatelliteUsed05', 'SatelliteUsed06',
                    'SatelliteUsed07', 'SatelliteUsed08', 'SatelliteUsed09',
                    'SatelliteUsed10', 'SatelliteUsed11', 'SatelliteUsed12',
                    'PDOP', 'HDOP', 'VDOP']
        # if len(sentence) > len(keywords):
        if self.NMEAVersion >= NMEA410:
            keywords.append('GNSSSystemID')
        return self._mixhash(keywords, sentence)

    def _GSV(self, sentence):
        """four of the sattelites seen"""
        keywords = ['NMEA', 'NofMessage', 'SequenceNr', 'SatellitesInView',
                    'SatelliteID1', 'Elevation1', 'Azimuth1', 'SNR1',
                    'SatelliteID2', 'Elevation2', 'Azimuth2', 'SNR2',
                    'SatelliteID3', 'Elevation3', 'Azimuth3', 'SNR3',
                    'SatelliteID4', 'Elevation4', 'Azimuth4', 'SNR4']
        # if len(sentence) > len(keywords):
        if self.NMEAVersion >= NMEA410:
            keywords.append('SignalID')
        return self._mixhash(keywords, sentence)

    def _pmtk_dt_release(self, sentence):
        """convert the release information from the message"""
        keywords = ['PMTK','ReleaseString', 'BuildID','ProductModel','SDK']
        return self._mixhash(keywords, sentence)

    def _pmtkAck(self, sentence):
        """convert the ack message"""
        keywords = ['PMTK', 'command', 'flag']
        return self._mixhash(keywords, sentence)

    def _pqverno(self, sentence):
        """convert the version message"""
        keywords = ['PMTK', 'command', 'ChipVersionID','date','time']
        return self._mixhash(keywords, sentence)

    def _pmtk(self, sentence, debug=False):
        """convert the anonymous pmtk message"""
        if debug:
            print(sentence[0])
        return dict(PMTK=sentence[0], msg=sentence)

    def _decodeNMEA(self, nmea, debug=False):
        """turns a message into a hash"""
        nmea_sentence = nmea[:-3].split(',')
        # sentence = nmea_sentence[0][3:]
        sentence = nmea_sentence[0][1:]
        nmea_sentence[0] = sentence
        if debug:
            print(sentence, "->", nmea_sentence)
        if sentence.endswith('RMC'):
            return self._RMC(nmea_sentence)
        if sentence.endswith('VTG'):
            return self._VTG(nmea_sentence)
        if sentence.endswith('GGA'):
            return self._GGA(nmea_sentence)
        if sentence.endswith('GSA'):
            return self._GSA(nmea_sentence)
        if sentence.endswith('GSV'):
            return self._GSV(nmea_sentence)
        if sentence.endswith('GLL'):
            return self._GLL(nmea_sentence)
        if sentence == 'PMTK705':
            return self._pmtk_dt_release(nmea_sentence)
        if sentence == 'PMTKLOG':
            return self._pmtk(nmea_sentence)
        if sentence == 'PMTK001':
            return self._pmtkAck(nmea_sentence)
        if sentence == 'PQVERNO':
            return self._pqverno(nmea_sentence)
        # if sentence.startswith('PMTK'):
        #     return self._pmtk(nmea_sentence)
        return None

    def _read_message(self, messagetype=('GLL',), timeout=None, debug=False):
        """read and decode a nmea sentence according to a messagetype"""
        # Sometimes messagetupe is a string.  Sometimes a tuple.
        # Make it always a tuple
        if not isinstance(messagetype, tuple):
                messagetype = (messagetype,)
        if debug:
            print("messagetype", messagetype)
        messagefound = False
        if timeout is None:
            timeout = self.timeout
        self.chrono.reset()
        self.chrono.start()
        chrono_running = True
        while not messagefound and chrono_running:
            if debug:
                print("--Checking Mesages--")
                print("Wanted messagetype(s)", messagetype)
            nmea_buffer = self._read().decode('utf-8')
            # Is messagetype present in the data
            for m in messagetype:
                if messagefound:
                    break
                if nmea_buffer.find(m):
                    # break apart the long string into segments
                    # NMEA messages end with \r\n
                    for segment in nmea_buffer.split("\r\n"):
                        if messagefound:
                            break
                        if debug:
                            print("segment", segment)
                        # Does this segment contain the message we're looking for?
                        if segment.find(m) > 0:
                            # Validate the whole message is present in segment
                            if segment.startswith("$") and segment[len(segment)-3:len(segment)-2] == "*":
                                # We now have what we want
                                # Decode segment
                                nmea_message = self._decodeNMEA(segment)
                                if debug:
                                    print("Decoded nmea_message", nmea_message)
                                self.lastmessage = nmea_message
                                messagefound = True
            if debug:
                print("found message?", messagefound)
            if self.chrono.read() > timeout or messagefound:
                self.chrono.stop()
                chrono_running = False
        if messagefound:
            return nmea_message
        else:
            return None

    def fixed(self):
        """fixed yet? returns true or false"""
        nmea_message = self.lastmessage
        pm = fs = False
        if nmea_message != {}:
            if nmea_message['NMEA'][2:] in ('RMC', 'GLL'):  # 'VTG',
                pm = nmea_message['PositioningMode'] != 'N'
            if nmea_message['NMEA'][2:] in ('GGA',):  # 'GSA'
                fs = int(nmea_message['FixStatus']) >= 1
        if pm or fs:
            self.fix = True
            self.timeLastFix = int(time.ticks_ms() / 1000)
            self.Latitude = nmea_message['Latitude']
            self.Longitude = nmea_message['Longitude']
        else:
            self.fix = False
            self.timeLastFix = 0xffffffff
            self.Latitude = None
            self.Longitude = None
            self.ttf = -1
        return self.fix

    def get_fix(self, force=True, debug=False, timeout=None):
        """look for a fix, use force to refix, returns true or false"""
        if force:
            self.fix = False
        if timeout is None:
            timeout = self.timeout
        self.chrono.reset()
        self.chrono.start()
        chrono_running = True

        while chrono_running and not self.fix:
            nmea_message = self._read_message(('RMC', 'VTG', 'GLL', 'GGA', 'GSA'), debug=debug)
            if nmea_message is not None:
                pm = fs = False
                try:
                    if nmea_message['NMEA'][2:] in ('RMC', 'GLL'):  #'VTG',
                        pm = nmea_message['PositioningMode'] != 'N'
                    if nmea_message['NMEA'][2:] in ('GGA', ):  #'GSA'
                        fs = int(nmea_message['FixStatus']) >= 1
                    if pm or fs:
                        self.chrono.stop()
                        self.fix = True
                        self.timeLastFix = int(time.ticks_ms() / 1000) - self.timeLastFix
                        self.ttf = round(self.chrono.read())
                        self.Latitude = nmea_message['Latitude']
                        self.Longitude = nmea_message['Longitude']
                except:
                    pass
            if self.chrono.read() > timeout:
                chrono_running = False
        self.chrono.stop()
        if debug:
            print("fix in", self.chrono.read(), "seconds")
        return self.fix

    def gps_message(self, messagetype=None, debug=False):
        """returns the last message from the L76 gps"""
        return self._read_message(messagetype=messagetype, debug=debug)

    def coordinates(self, debug=False):
        """you are here"""
        msg, latitude, longitude = None, None, None
        if not self.fix:
            self.get_fix(debug=debug)
        msg = self._read_message(('RMC', 'GGA', 'GLL'), debug=debug)
        if msg is not None:
            self.Latitude = msg['Latitude']
            self.Longitude = msg['Longitude']
        return dict(latitude=self.Latitude, longitude=self.Longitude, ttf=self.ttf)

    def get_speed_RMC(self):
        """returns your speed and direction as return by the ..RMC message"""
        msg, speed, COG = None, None, None
        msg = self._read_message(messagetype='RMC')
        if msg is not None:
            speed = msg['Speed']
            COG = msg['COG']
        return dict(speed=speed, COG=COG)

    def get_speed(self):
        """returns your speed and direction in degrees"""
        msg, speed, COG = None, None, None
        msg = self._read_message(messagetype='VTG')
        if msg is not None:
            speed = msg['SpeedKm']
            COG = msg['COG-T']
        return dict(speed=speed, COG=COG)

    def get_location(self, MSL=False,debug=False):
        """location, altitude and HDOP"""
        msg, latitude, longitude, HDOP, altitude = None, None, None, None, None
        if not self.fix:
            self.get_fix(debug=debug)
        msg = self._read_message(messagetype='GGA')
        if msg is not None:
            latitude = msg['Latitude']
            longitude = msg['Longitude']
            HDOP = msg['HDOP']
            if MSL:
                altitude = msg['GeoIDSeparation']
            else:
                altitude = msg['Altitude']
        return dict(latitude=latitude, longitude=longitude, HDOP=HDOP, altitude=altitude, ttf=self.ttf)

    def getUTCTime(self, debug=False):
        """return UTC time or None when nothing if found"""
        msg = self._read_message(('GLL','RMC','GGA'), debug=debug)
        if msg is not None:
            utc_time = msg['UTCTime']
            return "{}:{}:{}".format(utc_time[0:2], utc_time[2:4], utc_time[4:6])
        else:
            return None

    def getUTCDateTime(self, debug=False):
        """return UTC date time or None when nothing if found"""
        msg = self._read_message(messagetype='RMC', debug=debug)
        if msg is not None:
            utc_time = msg['UTCTime']
            utc_date = msg['Date']
            if str(utc_date)[-2:] == '80':
                return None
            return "20{}-{}-{}T{}:{}:{}+00:00".format(utc_date[4:6], utc_date[2:4], utc_date[0:2],
                                                      utc_time[0:2], utc_time[2:4], utc_time[4:6])
        else:
            return None

    def getUTCDateTimeTuple(self, debug=False):
        """return UTC date time or None when nothing if found"""
        msg = self._read_message(messagetype='RMC', debug=debug)
        if msg is not None:
            utc_time = msg['UTCTime']
            utc_date = msg['Date']
            if debug:
                print('utc_date type: %s' % type(utc_date))
            if str(utc_date)[-2:] == '80':
                return None
            year = '20'
            year += utc_date[4:6]
            return (int(year), int(utc_date[2:4]), int(utc_date[0:2]), int(utc_time[0:2]), int(utc_time[2:4]), int(utc_time[4:6]))
        else:
            return None

    def _query_pmtk(self, message=None, checksum=None, returnmessage=None, timeout=5, tries=12, debug=False):
        """query the gps chip for pmtk messages"""
        while tries >= 0:
            tries -= 1
            if debug:
                print("*"*20,tries,"*"*20)
            self._send_message(message=message, checksum=checksum, debug=debug)
            pmtk_answer = self._read_message(messagetype=returnmessage, timeout=timeout, debug=debug)
            if pmtk_answer is not None:
                if debug:
                    print(pmtk_answer)
                return pmtk_answer
        return None

    def get_locus_query_status(self, debug=False):
        """get the locus messages"""
        # TODO: work this out to read messages
        locus_status = self._query_pmtk(message='PMTK183', checksum='38', returnmessage='PMTKLOG')
        return locus_status

    def get_chip_version(self, debug=False):
        """get the version of the chip (non published command) """
        version = self._query_pmtk(message='PQVERNO,R',checksum='3F',returnmessage='PQVERNO',debug=debug)
        if debug:
            print(version)
        # keywords = ['PMTK', 'command', 'ChipVersionID','date','time']
        self.ChipVersionID = version['ChipVersionID']
        if int(version['ChipVersionID'][6:8]) > 1:
            self.NMEAVersion = 410
        else:
            self.NMEAVersion = 301
        return version

    def get_dt_release(self, debug=False):
        """get the chip version and release info"""
        dt_release = self._query_pmtk(message='PMTK605', checksum='31', returnmessage='PMTK705')
        if debug:
            print(dt_release)
        if dt_release is not None:
            rs = (dt_release['ReleaseString'].split('_'))[1]
            self.release = int('{}{:02d}'.format(rs.split('.')[0],int(rs.split('.')[1])))
            self.ReleaseString = dt_release['ReleaseString']
            self.BuildID = dt_release['BuildID']
            self.ProductModel = dt_release['ProductModel']
            self.SDK = dt_release['SDK']
        return dt_release

    def _send_message(self, message, checksum, debug=False):
        """ send message """
        checksum_calc = self._get_checksum(message)
        if checksum == checksum_calc:
            if debug:
                print(checksum, "ok")
            message = bytearray('${}*{}\r\n'.format(message,checksum))
            self.i2c.writeto(GPS_I2CADDR, message)
        else:
            print(checksum_calc , "<>", checksum)

    def enterStandBy(self, debug=False):
        """ standby mode, needs powercycle to restart"""
        message = bytearray('$PMTK161,0*28\r\n')
        self.i2c.writeto(GPS_I2CADDR, message)

    def hotStart(self, debug=False):
        """ HotStart the receiver, using data in nv store"""
        message = bytearray('$PMTK101*32\r\n')
        self.i2c.writeto(GPS_I2CADDR, message)
        self.fix = False
        # return self._read_message(messagetype='001', debug=debug)

    def warmStart(self, debug=False):
        """ warmStart the receiver, not using data in nv store, using last know messages"""
        message = bytearray('$PMTK102*31\r\n')
        self.i2c.writeto(GPS_I2CADDR, message)
        self.fix = False
        # return self._read_message(messagetype='001', debug=debug)

    def coldStart(self, debug=False):
        """ coldStart the receiver, not using any data """
        message = bytearray('$PMTK103*30\r\n')
        self.i2c.writeto(GPS_I2CADDR, message)
        self.fix = False
        # return self._read_message(messagetype='001', debug=debug)

    def fullColdStart(self, debug=False):
        """ full cold start the receiver, as cold start as in powercycle"""
        message = bytearray('$PMTK104*37\r\n')
        self.i2c.writeto(GPS_I2CADDR, message)
        self.fix = False
        # return self._read_message(messagetype='001', debug=debug)

    def setPeriodicMode(self, mode=0,
                        runtime=1000, sleeptime=1000,
                        secruntime=10000, secsleeptime=10000, debug=False):
        """
        mode :
            0 : fully on
            1 : periodic backup
            2 : periodic standby
            4 : perpetual standy by => needs powercycle to start the gps
            8 : allways locate standby
            9 : allways locate backup
        runtime: time the unit is fully operational
        sleeptime: time the unit is in standy/backup modus
        secruntime: time the unit is fully operation if the first runtime doesn't get a fix
        secsleeptime: time the unit is in standy/backup modus if the first runtime doesn't get a fix
        """
        if mode in (0, 1, 2, 8, 9):
            message = 'PMTK225,{},{},{},{},{}'.format(mode,runtime, sleeptime, secruntime, secsleeptime)
            checksum = self._get_checksum(message)
            message = bytearray('${}*{}\r\n'.format(message, checksum))
            if debug:
                print("setPeriodicMode",message)
            self.i2c.writeto(GPS_I2CADDR, message)
        # return self._read_message(messagetype='001', debug=debug)

    def setAlwaysOn(self, debug=False):
        self.setPeriodicMode(mode=0)

    def setAlwaysLocateMode(self, mode=8, debug=False):
        if mode in (8, 9):
            message = 'PMTK225,{}'.format(mode)
            checksum = self._get_checksum(message)
            message = bytearray('${}*{}\r\n'.format(message, checksum))
            if debug:
                print("setAlwaysLocateMode",message)
            self.i2c.writeto(GPS_I2CADDR, message)
            return True
            # response = self._read_message(messagetype='001', debug=debug)
            # if debug:
            #     print("response",response)
            # if response['response'] == 3:
            #     return True
            # else:
            #     return False

    def _get_checksum(self, message):
        """calculates the checksum"""
        mc = ord(message[0])
        for m in message[1:]:
            mc = mc ^ ord(m)
        return '{:02x}'.format(mc).upper()

    def _check_checksum(self, message):
        """check the checksum of the message"""
        message = message[1:]
        message, checksum = message.split('*')
        return self._get_checksum(message) == checksum