#!/usr/bin/python3
"""
A bridge between PE1RXF APRS telemetry messaging and MQTT .
It uses pythonax25 ( https : / / github . com / josefmtd / python - ax25 )
( C ) 2023 M . T . Konstapel https : / / meezenest . nl / mees
This file is part of aprs_telemetry_to_mqtt .
aprs_telemetry_to_mqtt is free software : you can redistribute it and / or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation , either version 3 of the License , or
( at your option ) any later version .
aprs_telemetry_to_mqtt is distributed in the hope that it will be useful ,
but WITHOUT ANY WARRANTY ; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
GNU General Public License for more details .
You should have received a copy of the GNU General Public License
along with aprs_telemetry_to_mqtt . If not , see < https : / / www . gnu . org / licenses / > .
"""
import sys
import random
import time
import os
from pathlib import Path
import yaml
from yaml . loader import SafeLoader
from paho . mqtt import client as mqtt_client
import csv
import pythonax25
axport = [ ]
axdevice = [ ]
axaddress = [ ]
class aprs_status :
nr_of_ports = 0
busy = 0
wait_for_ack = 0
call_of_wait_for_ack = 0
time_out_timer = 0
retry_counter = 0
selected_port = 0
request_to_send = 0
pass
aprs = aprs_status ( )
configuration_file = " aprs_telemetry_to_mqtt.yml "
# This is where we keep our settings
class mqtt_settings :
#broker
#topic_root
#port
#client_id
#transmit_rate
#retry
#topics
pass
mqtt = mqtt_settings ( )
def connect_mqtt ( ) :
def on_connect ( client , userdata , flags , rc ) :
if rc == 0 :
print ( " Connected to MQTT Broker! " )
else :
print ( " Failed to connect, return code %d \n " , rc )
# Set Connecting Client ID
client = mqtt_client . Client ( mqtt . client_id )
#client.username_pw_set(username, password)
client . on_connect = on_connect
client . connect ( mqtt . broker , mqtt . port )
return client
def publish ( client , topic , message ) :
result = client . publish ( topic , message )
status = result [ 0 ]
if status == 0 :
print ( f " Send ` { message } ` to topic ` { topic } ` " )
else :
print ( f " Failed to send message to topic { topic } " )
def subscribe ( client : mqtt_client , topic ) :
def on_message ( client , userdata , message ) :
received_payload = message . payload . decode ( )
received_topic = Path ( message . topic ) . name
print ( f " Received ` { received_payload } ` from ` { message . topic } ` topic " )
# Find corresponding topic in configuration-file and send this to the next function
for topics in mqtt . topics :
if received_topic == topics [ ' name ' ] :
#print ('Found topic in list!')
#print (topics)
process_message ( topics , received_payload )
break
# print(topic['name'])
# print(topic['command'])
client . subscribe ( topic )
client . on_message = on_message
def read_config ( ) :
try :
with open ( configuration_file ) as f :
cfg = yaml . load ( f , Loader = SafeLoader )
mqtt . topics = cfg [ ' topics ' ]
#print(mqtt.topics)
#for topic in mqtt.topics:
# print(topic['name'])
# print(topic['command'])
except :
print ( " Configuration file ./ " + configuration_file + " not found. " )
sys . exit ( 1 )
try :
mqtt . broker = cfg [ ' global ' ] [ ' broker ' ]
except :
print ( " Error in configuration file: no broker defined. " )
sys . exit ( 1 )
try :
mqtt . port = cfg [ ' global ' ] [ ' port ' ]
except :
print ( " Error in configuration file: no port defined. " )
sys . exit ( 1 )
try :
mqtt . topic_root = cfg [ ' global ' ] [ ' topic_root ' ]
except :
print ( " Error in configuration file: no topic defined. " )
sys . exit ( 1 )
mqtt . client_id = f ' { mqtt . topic_root } - { random . randint ( 0 , 1000 ) } '
print ( mqtt . broker )
print ( mqtt . topic_root )
print ( mqtt . port )
print ( mqtt . client_id )
# Loop through all topics and activate them
def add_subscribtions_from_configfile ( client ) :
for topics in mqtt . topics :
current_topic = mqtt . topic_root + ' / ' + topics [ ' name ' ]
subscribe ( client , current_topic )
print ( ' Topic ' + topics [ ' name ' ] + ' added ' )
# Loop through all topics and send telemtry to broker
def send_telemetry_to_broker ( client ) :
for topics in mqtt . topics :
#for topics in topics.description:
#print('Description ' + topics['description'] + ' added')
#print(topics['description'])
# Loop through descriptions and send values from telemtry file along with it
#for descr in topics['description']:
for index , descr in enumerate ( topics [ ' description ' ] , start = 0 ) :
#print(descr)
# Read telemetry data
with open ( topics [ ' telemetry_file ' ] , newline = ' ' ) as csvfile :
telemetry_reader = csv . reader ( csvfile , delimiter = ' , ' , quotechar = ' | ' )
# there should only be one row in the telemetry file, but try to read all lines anyway
for row in telemetry_reader :
current_topic = mqtt . topic_root + ' / ' + topics [ ' name ' ] + ' / ' + descr
publish ( client , current_topic , row [ index ] )
#print(current_topic + '=' + row[index])
# AX.25 stuff
def parsePacket ( string ) :
# Split the address and payload separated by APRS PID
buffer = string . split ( b ' \x03 \xf0 ' )
address = buffer [ 0 ]
# Check if the first byte indicates it is a data packet
if address [ 0 ] == 0 :
# Cut the first byte and feed it to the address parser
listAddress = getAllAddress ( address [ 1 : ] )
if listAddress != 0 :
# Get the source, destination, and digipeaters from the address list
source = listAddress [ 1 ]
destination = listAddress [ 0 ]
digipeaters = listAddress [ 2 : ]
payload = buffer [ 1 ]
else :
# If there was an error decoding the address we return save values which will be ignored by the rest of the program
source = ' NOCALL '
destination = ' NOCALL '
digipeaters = ' NOCALL '
payload = ' NOT VALID '
else :
# If there was an error decoding the address we return save values which will be ignored by the rest of the program
source = ' NOCALL '
destination = ' NOCALL '
digipeaters = ' NOCALL '
payload = ' NOT VALID '
#raise Exception('Not a data packet')
return ( source , destination , digipeaters , payload )
def getAllAddress ( packetAddress ) :
addressSize = 7
# Check if the networked address string is valid
if ( len ( packetAddress ) % 7 ) == 0 :
# Create a list of all address in ASCII form
try :
allAddress = [ pythonax25 . network_to_ascii ( packetAddress [ i : i + addressSize ] )
for i in range ( 0 , len ( packetAddress ) , addressSize ) ]
except :
allAddress = 0
return allAddress
else :
# Received a non valid address. Fill return value with NULL so we don't crash
allAddress = 0
return allAddress
#raise Exception('Error: Address is not a multiple of 7')
def bind_ax25 ( ) :
# Check if there's any active AX25 port
current_port = 0 ;
port_nr = pythonax25 . config_load_ports ( )
aprs . nr_of_ports = port_nr
if port_nr > 0 :
# Get the device name of the first port
axport . append ( pythonax25 . config_get_first_port ( ) )
axdevice . append ( pythonax25 . config_get_device ( axport [ current_port ] ) )
axaddress . append ( pythonax25 . config_get_address ( axport [ current_port ] ) )
print ( axport [ current_port ] , axdevice [ current_port ] , axaddress [ current_port ] )
current_port = current_port + 1
while port_nr - current_port > 0 :
axport . append ( pythonax25 . config_get_next_port ( axport [ current_port - 1 ] ) )
axdevice . append ( pythonax25 . config_get_device ( axport [ current_port ] ) )
axaddress . append ( pythonax25 . config_get_address ( axport [ current_port ] ) )
print ( axport [ current_port ] , axdevice [ current_port ] , axaddress [ current_port ] )
current_port = current_port + 1
else :
exit ( 0 )
# Initiate a PF_PACKET socket (RX)
rx_socket = pythonax25 . packet_socket ( )
return rx_socket
def receive_ax25 ( rx_socket ) :
# Blocking receive packet, 10 ms timeout
receive = pythonax25 . packet_rx ( rx_socket , 10 )
return receive
def send_ax25 ( portCall , srcCall , dest , digi , msg ) :
# Initiate a datagram socket (TX)
tx_socket = pythonax25 . datagram_socket ( )
res = pythonax25 . datagram_bind ( tx_socket , srcCall , portCall )
#print(res)
if digi == 0 :
res = pythonax25 . datagram_tx ( tx_socket , dest , msg )
else :
res = pythonax25 . datagram_tx_digi ( tx_socket , dest , digi , msg )
#print(res)
pythonax25 . close_socket ( tx_socket )
def process_message ( source , ax_port , payload , mqtt_client ) :
#print(source)
#print(axdevice[ax_port])
#print(payload)
#print(axaddress[ax_port])
values = 0
for topics in mqtt . topics :
# Check source call
if source == topics [ ' call ' ] :
# Check ax_port
if topics [ ' ax_port ' ] == ' all ' or topics [ ' ax_port ' ] == axdevice [ ax_port ] :
#print('Call found in configuration file')
# split payload at colon. If it is a valid reply, we should get three
# substrings: the first in empty, the second with the call of the ax25
# interface and the thirth with the status of the outputs
split_message = payload . split ( " : " )
if len ( split_message ) == 3 :
#Remove spaces from destination call and test if message is for the server
if split_message [ 1 ] . replace ( " " , " " ) == axaddress [ ax_port ] :
print ( ' Received from: ' + source + ' Telemetry: ' + split_message [ 2 ] )
# The telemetry is available in split_message[2], but we have to check if it contains any valid data
# Try to split into seperate values (values should be seperated by a comma)
values = split_message [ 2 ] . split ( " , " )
# Test al values: should be numbers and nothing else
for field in values :
if not is_float ( field ) :
return 0
# One anoying thing of the PE1RXF telemetry standard is that there is also a message containing the status of the output bits.
# These messages are interpreted as valid telemetry data by this program. The message is send after a '06' command. This program
# does not request this message, but another program might. So we have to filter these messages output
if len ( values [ 0 ] ) == 5 :
allowed = ' 0 ' + ' 1 '
# Removes from the original string all the characters that are allowed, leaving us with a set containing either a) nothing, or b) the #offending characters from the string:'
if not set ( values [ 0 ] ) - set ( allowed ) :
print ( " Probably digital status bits. Ignore. " )
return 0
# Check if number of teleemtry values and number of descriptions in yml file are the same. If not make then the same by appending to the shorted list.
nr_of_values = len ( values )
nr_of_descriptions = len ( topics [ ' description ' ] )
if nr_of_values > nr_of_descriptions :
items_to_add = nr_of_values - nr_of_descriptions
for x in range ( items_to_add ) :
topics [ ' description ' ] . append ( ' NotDefined ' )
print ( ' Added ' + str ( items_to_add ) + ' to descriptions ' )
elif nr_of_values < nr_of_descriptions :
items_to_add = nr_of_descriptions - nr_of_values
for x in range ( items_to_add ) :
values . append ( ' 0.0 ' )
print ( ' Added ' + str ( items_to_add ) + ' to values ' )
else :
print ( ' values and description are of equal length: good! ' )
# Loop through descriptions and send values from telemtry file along with it
for index , descr in enumerate ( topics [ ' description ' ] , start = 0 ) :
current_topic = mqtt . topic_root + ' / ' + topics [ ' name ' ] + ' / ' + descr
publish ( mqtt_client , current_topic , values [ index ] )
#print('Publish ' + current_topic + '=' + values[index])
return values
# End AX.25 stuff
# Test if string is a number
def is_float ( v ) :
try :
f = float ( v )
except :
return False
return True
def run ( ) :
read_config ( )
rx_socket = bind_ax25 ( )
client = connect_mqtt ( )
add_subscribtions_from_configfile ( client )
#topic = mqtt.topic_root + '/set'
#subscribe(client,topic)
client . loop_start ( )
while True :
receive = receive_ax25 ( rx_socket )
for port in range ( len ( axdevice ) ) :
if receive [ 0 ] [ 1 ] == axdevice [ port ] :
#print(receive)
source , destination , digipeaters , payload = parsePacket ( receive [ 1 ] )
# bug UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf8 in position 47: invalid start byte
try :
payload = payload . decode ( )
except :
payload = ' NOT VALID '
#print("Packet Received by = %s"%axaddress[0])
#print("Source Address = %s"%source)
#print("Destination Address = %s"%destination)
#print("Digipeaters =")
#print(digipeaters)
#print("Payload = %s"%payload)
#print("")
telemetry = process_message ( source , port , payload , client )
if __name__ == ' __main__ ' :
#sys.stdout = sys.stderr = open('debug.log', 'w')
run ( )