Skip to content

Señales GPS por UDP en QGIS, una aproximación

Jorge Tornero edited this page Dec 16, 2019 · 1 revision

QGIS proporciona un panel de GPS muy completo que nos permite conectar a un GPS a través de puerto serie o una conexión gpsd que proporcione sentencias en formato NMEA 0182. Pero el panel tiene dos limitaciones importantes:

En primer lugar solamente permite conexiones serie y gpsd, por lo que no es posible, recibir datagramas UDP con datos NMEA. Esta situación puede que sea rara para la mayoría de usuarias de QGIS, pero es habitual en buques de investigación, en los que no se puede llegar con los grifos RS232 a todas partes y las señales de GPS y sonda se transmiten por UDP.

Pero por otro lado la funcionalidad de análisis de datos NMEA de QGIS tiene ciertas limitaciones: Hasta lo que yo alcanzo a comprender, si miramos el código fuente de QGIS relativo a la gestión de datos GPS en qgsnmeaconnection.cpp, sólo es posible extraer información de ciertas sentencias NMEA: GGA, RMC, GSV, VTG, GSA,GST y HDT. Sentencias tan habituales como GLL o ZDT quedan fuera y hacen imposible obtener información útil de las tramas que se reciben. Y, por supuesto, información de sonda u otros instrumentos queda totalmente fuera del alcance.

Pero hay otro problema más en el módulo GPS: Para reconocer la sentencia NMEA utiliza la cabecera completa, es decir, reconoce la sentencia GGA o RMC porque analiza la presencia de $GPGGA o $GPRMC en el datagrama, con lo cual no reconoce sentencias NMEA perfectamente formadas pero que en lugar de el prefijo de emisor genérico GP utilicen otro: QGIS ignorará una sentencia $INGGA proveniente de un Seatex SeaPath perfectamente formateada, por ejemplo.

Para mí, lo ideal es tener cuantos menos softwares funcionando al mismo tiempo sea posible y en mi caso intento que QGIS sea mi único software para trabajar datos espaciales a bordo, incluyendo esto ver por dónde estamos navegando, por ejemplo. Es habitual utilizar el excelente OpenCPN pero se pierden todas las funcionalidades propias de QGIS. Por eso en esta ocasión os voy a contar cómo podemos lograrlo y ya de paso aprendemos algunas cosas de QGIS que a mí me están resultando cada vez más atractivas y que consisten, básicamente, en personalizarlo a través de PyQGIS.

Como siempre, vaya por delante la advertencia de que no me dedico a programar ni soy programador, sino que intento usar la programación en mi beneficio de la mejor manera que sé, y no es, natural ni necesariamente, ni la más eficiente ni la mejor. Sin perjuicio de que aunque intento expresarme en los mejores términos posibles, es muy probable que pegue unas cuantas patadas al diccionario de los programadores... Pero el caso es que lo que os cuento aquí, aunque pueda ser aberrante, funciona... si lo veis mal, pues ya lo apañáis vosotros.

Un pequeño detalle que no quiero que se me escape. Lo ideal es disponer de un buque oceanográfico (a ser posible, de más de 80 metros de eslora, para no marearnos mucho) para tener la conexión a los GPS, pero si no tenéis uno a mano podéis usar un emulador de GPS. Yo uso gpsfeed+, que podéis descargar aquí y es estupendo porque permite emular un GPS mediante varias sentencias NMEA preestablecidas o personalizarlas, con lo cual podéis usar cualquier sentencia NMEA o análoga. Eso sí... he tenido que tunearlo porque la gestión de las sentencias NMEA que hace no permite que esto funcione bien (envía una sentencia por datagrama con el fin de linea erróneo), pero con esta modificación funciona:

# archivo gpsfeed+.tcl
# Buscar este fragmento y dejarlo así

	if {$prefs(udp) & $::udpOn} {
	
        puts -nonewline $::u $::out
		# one sentence per udp packet
# 		foreach line [split $::out \n] {
# 			puts -nonewline $::u $line
# 			#flush $::u
# 		}
}

Recibir y usar información GPS por UDP en QGIS

En principio, esto va a ser tan simple como ser capaces de leer las sentencias UDP y retransmitirlas para que QGIS pueda hacerse cargo de ellas. Para conseguirlo vamos a crear un socket UDP que las lea y un servidor TCP que sea a quien QGIS se conecte mediante el panel GPS.

Podríamos usar el módulo socket de Python, pero creo que es mejor usar el módulo QtNetwork de PyQt5. Primero porque creo que se integra mucho mejor con PyQGIS. En segundo lugar, porque todo el invento de las signals y los slots de Qt viene de maravilla para nuestros fines.

La consola de QGIS importa automáticamente varios de los módulos de PyQt5, pero entre ellos no se encuentra QtNetwork. De manera que lo primero que haremos será importar ese módulo. Abriremos la consola de Python en QGIS (Complementos->Consola de Python, o pulsamos sobre el icono de la barra de herramientas) y tecleamos

from PyQt5 import QtNetwork

Creo que la manera mejor de implementarlo es mediante un widget de Qt. De esta manera, luego podremos usarlo de alguna otra manera muy interesante, como veréis.

El widget constará de unos QLineEdit para mostrar información y un botón de arranque y otro de parada.

Os voy a poner el código de definición de la clase directamente con comentarios y luego haremos alguna cosilla más con él

 class udpator(QWidget):

    def __init__(self, port=4002):
        
        super(QWidget,self).__init__()
        self.port = port
        self.setWindowTitle('GPS-UDP')
        
        # Creacion del UI 
        self.layout = QHBoxLayout(self)
        self.conectado = QLineEdit('Desconectado')
        self.recibiendo = QLineEdit()
        self.enviando = QLineEdit()
        self.conectado.setReadOnly(True)
        self.recibiendo.setReadOnly(True)
        self.enviando.setReadOnly(True)
        self.conectado.setFixedWidth(100)
        self.enviando.setFixedWidth(100)
        self.recibiendo.setFixedWidth(100)
        # Los espacios al principio son para que al hacer scroll quede bien
        self.textEnviando='           Enviando'
        self.textRecibiendo='         Recibiendo'
        self.botonInicio = QPushButton('INICIO')
        self.botonFinal = QPushButton('FIN')
        self.botonInicio.setEnabled(True)
        self.botonFinal.setEnabled(False)
        self.botonInicio.setFixedWidth(75)
        self.botonFinal.setFixedWidth(75)
        self.layout.addWidget(self.conectado)
        self.layout.addWidget(self.recibiendo)
        self.layout.addWidget(self.enviando)
        self.layout.addWidget(self.botonInicio)
        self.layout.addWidget(self.botonFinal)
        self.layout.addStretch()
        
        # Creamos el socket UDP que recibirá el NMEA
        self.leeGPS = QtNetwork.QUdpSocket()
        
        # Y ahora el servidor TCP al que se conectará
        # el panel GPS de QGIS
        self.servidor = QtNetwork.QTcpServer(self)
        self.servidor.listen(port=2947)        
        
        # Aquí almacenaremos el socket que devuelve el servidor
        # al recibir una conexión
        self.serverSocket = None
        
        # Conectamos las señales de los botones
        self.botonInicio.clicked.connect(self.iniciarUdpTcp)
        self.botonFinal.clicked.connect(self.pararUdpTcp)
        
        #self.show()
        
    def iniciarUdpTcp(self):
    
        # Lo que hacemos para iniciar la transferencia es conectar la señal 
        # readyRead de QUdpSocket con el slot enviaNMEA.
        # También conectaremos la señal newConnection con el slot sirveGPS
        # que es el que se encarga de gestionar las conexiones recibidas
        # por el QTcpServer
        # Luego haremos la operación inversa para parar
        
        self.servidor.newConnection.connect(self.gestionaConexion)
        self.leeGPS.bind(port=self.port)
        self.leeGPS.readyRead.connect(self.enviaNMEA)
        
        self.botonInicio.setEnabled(False)
        self.botonFinal.setEnabled(True)
    
    def pararUdpTcp(self):
    
        # Paramos la recepción y envío de datos
        self.servidor.newConnection.disconnect()
        self.leeGPS.readyRead.disconnect()
        self.leeGPS.disconnectFromHost()
        self.serverSocket.disconnectFromHost()
        self.serverSocket = None
        self.conectado.setText('Desconectado')
        self.conectado.setStyleSheet('QLineEdit{background: white;}')
        self.botonInicio.setEnabled(True)
        self.botonFinal.setEnabled(False)
        
    def enviaNMEA(self):
        
        # Siempre que se reciba un datagrama lo procesaremos
        # e intentaremos enviarlo
        
        # Leemos el datagrama
        
        datos = self.leeGPS.readDatagram(1024)
        # print(datos[0]) Para ver en la consola el datagrama entrante
        # Si hubiera conexión enviamos el datagrama a su destino
        
        if self.serverSocket == None:
            return
        else:
            # Para dar a entender que se envian datagramas
            # hacemos scroll en el texto del QLineEdit correspondiente
            self.textEnviando = self.textEnviando[1:] + self.textEnviando[0]
            self.enviando.setText(self.textEnviando)
            # Envío de datos
            self.serverSocket.write(datos[0])
            self.serverSocket.flush()
        
        # E igual hacemos con la recepcion
        
        self.textRecibiendo = self.textRecibiendo[1:] + self.textRecibiendo[0]
        self.recibiendo.setText(self.textRecibiendo)
        
    def gestionaConexion(self):
        
        # Cuando se recibe una conexión al servidor, se almacena en la variable
        # correspondiente y se refleja en el UI
        
        self.conectado.setText('Conectado')
        self.conectado.setStyleSheet('QLineEdit{background: lightgreen;}')
        self.serverSocket=self.servidor.nextPendingConnection()

Aquí la gracia está en que el servidor en sí no es capaz de transmitir nada. Lo que hace el servidor es que cuando recibe una conexión nos devuelve un objeto QTcpSopcket que conecta servidor y cliente a través del cual sí que podemos hacer la transferencia de datos. Tenemos que transmitir las sentencias recibidas por UDP al servidor, y eso es lo que Qt nos facilita enormemente mediante el mecanismo de signals y slots.

Integración con QGIS

Ya hemos creado nuestro widget y podemos verlo si escribimos en la consola:

gpsUdp=udpator()
gpsUdp.show()

El método de operación es sencillo, pulsamos Inicio y se comienza a recibir NMEA por UDP. Ahora, si conectamos al GPS mediante el panel GPS de QGIS, si todo va bien (esto es experimental, jajajaj) veremos que en la barra de herramientas cambia el estado de la conexión y vemos si se envían y reciben datos. Si pulsamos FIN se detiene la transferencia. Para volver a recibir en QGIS, pulsamos INICIO y reconectamos desde el panel GPS (cruzando los dedos, claro)

Pero tener una ventanita flotando por ahí no nos sirve de mucho... vamos a integrarlo en QGIS como una barra de herramientas. Tenemos que crear una QToolBar e integrarla en las barras de herramientas de QGIS. Parece complicado pero es muy sencillo, gracias a que en la consola de QGIS se puede acceder directamente al interfaz y sus métodos mediante iface:

# Creamos la QToolBar
gpsUdpTool=QToolBar('GPS-UDP')

# Le añadimos nuestro widget
gpsUdpTool.addWidget(gpsUdp)

# Y añadimos la barra de herramientas al interfaz de QGIS
iface.addToolBar(gpsUdpTool)

Ahora ya queda mucho mejor, ¿no?

¿Y ahora qué?

Recibir sentencias GPS no estándar

Pues lo primero que podríamos hacer es permitir que se reciban otras sentencias no estándar. Para ello simpemente tendríamos que tratar la sentencia recibida para que la entienda el panel GPS.

Por ejemplo, para permitir que se pudieran recibir sentencias GGA provenientes de Seatex Seapath, simplemente tendremos que sustituir en la cabecera del datagrama el emisor de la sentencia por el GP geneŕico que sí entiende. Para eso alteramos un poco la función envíaNMEA de nuestro widget modificando algunas líneas

# Envío de datos
datos=datos[0].replace(b'$IN',b'$GP')
self.serverSocket.write(datos)

Utilizar sentencias no admitidas por QGIS

Anteriormente hemos dicho que el panel QGIS sólo admite ciertas sentencias NMEA. ¿Y si nuestro GPS nos envía la información mediante una sentencia no soportada, como, por ejemplo, GLL? Pues vamos a "falsificar" una sentencia que sí entienda con los datos que tengamos.

Aquí es donde el módulo python pynmea2 nos va a echar un cable. Este módulo permite tanto parsear como crear sentencias NMEA con facilidad. Analizaremos la sentencia de entrada, tomaremos los datos que nos convengan y crearemos la sentencia que nos convenga a partir de esos datos. Examinad la documentación sobre NMEA 0183 y pynmea2 para más detalles.

Un problema reside en que no necesariamente tenemos todos los datos necesarios en una sentencia determinada para crear otra, pero podemos tener un equivalente razonable.

Como este módulo no está disponible en QGIS por defecto, lo importamos

import pynmea2

Imaginemos que nuestro GPS nos envía sentencias GLL. Modificaremos la misma parte de la función enviaNMEA. Para evitarnos engorros, vamos a suponer que las sentencias llegan en datagramas de una en una y no tenemos que separarlas, en caso contrario tendríamos que gestionar esto.

# Envío de datos
datos=str(datos[0])
# Analizamos y extraemos información del GLL recibdo
datagramaGLL = pynmea2.parse(datos) # Con esto analizamos el GLL recibido
# Creamos la nueva sentencia
datagramaGGA = pyNMEA2.GGA('GP','GGA',<y aquí pondríamos los términos adecuados extraídos del datagramaGLL>) 
# Y se la enviamos a QGIS
self.serverSocket.write(bytes(datagramaGGA))

Recibir información no GPS

Otra cosa que podríamos hacer con el mismo mecanismo es ampliar el rango de instrumentos de los que recibir información en QGIS. Es muy sencillo crear un socket que reciba información de sondas, o anemómetros, o lo que queramos, y los incorpore a una capa o nos lo muestre en pantalla. Sin necesidad de complicaciones, ni siquiera creando clases, podemos hacer que se muestre por pantalla la información que nos llegue desde una sonda de un barco:

Creamos el socket UDP

leeSonda=QtNetwork.QUdpSocket()
leeSonda.bind(port=4002)

Creamos un widget para mostrar la información y lo añadimos al interfaz de QGIS

textoSonda=QLineEdit()
tb2=QToolBar('SONDA')
tb2.addWidget(textoSonda)
iface.addToolBar(tb2)

Ahora escribimos una función que lea los datagramas y actualice el texto

def lectorSonda():
    while leeSonda.hasPendingDatagrams():
        datos=leeSonda.readDatagram(1024)
        textoSonda.setText(str(datos)[0])

Lo unimos todo mediante el sistema de signals y slots

leeSonda.readyRead.connect(lectorSonda)

Y ya podemos recibir datos desde nuestra sonda o intrumento y procesarlo en QGIS.

Pues ahí lo dejo, hay mucho espacio de mejora... pero para arrancar con este tipo de personalizaciones, vale.

Saludos de @imasdemase