#!/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 . """ 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 # 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()