From ebbaceaeea280e22ebe25b476a7291de4564cb1c Mon Sep 17 00:00:00 2001 From: marcel Date: Fri, 13 Jan 2023 15:52:42 +0100 Subject: [PATCH] First working version. --- CHANGELOG.md | 13 ++ README.md | 67 ++++++ aprs-mqtt-bridge.py | 385 +++++++++++++++++++++++++++++++ aprs-mqtt-bridge.yml | 77 +++++++ python-ax25/README.md | 115 +++++++++ python-ax25/examples/readAPRS.py | 65 ++++++ python-ax25/examples/sendAPRS.py | 49 ++++ python-ax25/install.sh | 17 ++ python-ax25/pythonax25module.c | 352 ++++++++++++++++++++++++++++ python-ax25/setup.py | 10 + 10 files changed, 1150 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100755 aprs-mqtt-bridge.py create mode 100644 aprs-mqtt-bridge.yml create mode 100644 python-ax25/README.md create mode 100755 python-ax25/examples/readAPRS.py create mode 100755 python-ax25/examples/sendAPRS.py create mode 100755 python-ax25/install.sh create mode 100644 python-ax25/pythonax25module.c create mode 100755 python-ax25/setup.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..604806e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + + Added : for new features. + Changed : for changes in existing functionality. + Deprecated: for soon-to-be removed features. + Removed : for now removed features. + Fixed : for any bug fixes. + Security : in case of vulnerabilities. + +## [1.0.0] - 2023-01-13 +First working version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a6d4f8 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# APRS to MQTT bridge + +The APRS to MQTT bridge can relay commands from an MQTT broker to the APRS nodes via the Linux AX.25 stack. For now, only commands which response with a defined acknowledge (commands 10 and higher) are supported. + +This program is a utility for the APRS telemetry system used by PE1RXF. The telemetry is embedded in an APRS message which can travel over the existing APRS network. For more information about this open protocol visit this link: https://www.meezenest.nl/mees-elektronica/projects/aprs_telemetry/APRS_protocol_nodes_PE1RXF.pdf + +## Configuration + +The program is configured via a YAML file. The global section defines the MQTT broker and some APRS transmit settings. The topics define the MQTT topics on which a client can publish a request. The full path of the topic is 'topic_root/topic_name'. Call, server and port define the AX.25 settings where 'call' is the call of the APRS node the message is send to, 'server' is the call of the APRS server which sends the message (typically this is the call assigned to the ax25 port) and 'port' is the Linux AX.25 port on which the radio is connected. + +Lets say we have an APRS node which can switch several power rails. (https://www.meezenest.nl/mees-elektronica/RPi-pico-LoRa-APRS.html). We connected a 5GHz HamNet dish to output 2 of this APRS node. The node is assigned the call 'PE1RXF-6'. Assume this node can be reached via the radio connected to AX.25 port ax2 on our server. The node switches the output to high when it receives command '33{33'. We want the bridge to send this command when it receives payload 'ON' via MQTT on topic 'hamnet_aprs_nodes/ubiquity_dish_ptmp_workshop'. With the below example configuration, we can achieve just that. And by sending MQTT payload 'OFF', the APRS node switches the ouptut off again. + +Multiple topics for the same or another APRS node can be defined as shown in the example. + +``` +# Global settings apply to all other entries +global: + broker: pe1rxf.ampr.org # The MQTT broker we are going to use + port: 1883 # The tcp port of the MQTT broker + topic_root: hamnet_aprs_nodes # MQTT topic root + transmit_rate: 20 # Number of seconds between each transmision + retry: 3 # Try this often before giving up + destination: APRX29 # Destination or program ID + digi_path: WIDE2-1 # Digi path of APRS messages + #beacon_program: /usr/sbin/beacon # The external AX.25 beacon program => obsolete + + +topics: + # MQTT topic: 5GHz dish at workshop (must be unique name) + - name: ubiquity_dish_ptmp_workshop + call: PE1RXF-6 # Call of node to which commands below are send + server: PE1RXF-3 # Call of APRS server sending the commands + port: ax2 # Name of AX.25 port to use + command: + - payload: 'ON' # This is the payload we have to receive + cmd: 33{33 # This command is send to the node + response: ack33 # This response is expected from the node + - payload: 'OFF' + cmd: 32{32 + response: ack32 + + # Server at tiny house + - name: server_tiny_house + call: PE1RXF-7 + server: PE1RXF-3 + port: ax2 + command: + - payload: 'ON' + cmd: 31{31 + response: ack31 + - payload: 'OFF' + cmd: 30{30 + response: ack30 +``` +The configuration entry 'global:retry' sets the amount of APRS message retries we attempt before giving up. The configuration entry 'global:transmit_rate' sets the time between retries. + +For now, the state of the outputs of the APRS nodes is not published to the MQTT broker. In the future, this could be implemented in the software. It is possible to poll the nodes via command '06' to get the current state of the outputs. + +## Requirements + +- Python3 +- Python AX.25 Module for Python3 (https://github.com/ha5di/pyax25) +- pathlib +- yaml +- paho-mqtt +- Linux AX.25 stack + diff --git a/aprs-mqtt-bridge.py b/aprs-mqtt-bridge.py new file mode 100755 index 0000000..34346b1 --- /dev/null +++ b/aprs-mqtt-bridge.py @@ -0,0 +1,385 @@ +#!/usr/bin/python3 +""" + A bridge between APRS messaging and MQTT, designed to control my lora_aprs_node_pico + + (C)2022 M.T. Konstapel https://meezenest.nl/mees + + This file is part of aprs-mqtt-bridge. + + aprs-mqtt-bridge 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-mqtt-bridge 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-mqtt-bridge. 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 pythonax25 + +configuration_file = "aprs-mqtt-bridge.yml" + +# This is where we keep our settings +class mqtt_settings: + #broker + #topic_root + #port + #client_id + #transmit_rate + #retry + #topics + state = 'ready' + pass +mqtt = mqtt_settings() + +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() + +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 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) + try: + mqtt.transmit_rate = cfg['global']['transmit_rate'] + except: + print ("Error in configuration file: no transmit_rate defined.") + sys.exit(1) + try: + mqtt.retry = cfg['global']['retry'] + except: + print ("Error in configuration file: no retry defined.") + sys.exit(1) + try: + mqtt.destination = cfg['global']['destination'] + except: + print ("Error in configuration file: no retry 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') + +def process_message(data, payload): + #print ('Payload: '+ payload) + #print (data['call']) + #print (data['port']) + + if aprs.busy == 0: + # find payload in configuration file + for commands in data['command']: + if payload == commands['payload']: + aprs.time_out_timer = time.time() # Start timeout timer + aprs.busy = 1 + aprs.selected_port = data['port'] + + # Find call of ax25 port + for position in range(len(axdevice)): + if axdevice[position] == aprs.selected_port: + aprs.port_call = axaddress[position] + + aprs.source_call = data['server'] + aprs.wait_for_ack = commands['response'] + aprs.call_of_wait_for_ack = data['call'] + aprs.message = ':' + data['call'].ljust(9) + ':' + commands['cmd'] + arguments = '-d \"APRX29\" -s ' + data['port'] + ' \"' + aprs.message + '\"' + beacon_program = "/usr/sbin/beacon" + aprs.beacon_program_with_arguments = beacon_program + " " + arguments + #os.system(aprs.beacon_program_with_arguments) + print ('APRS message ' + aprs.message + ' send to ' + aprs.call_of_wait_for_ack + '.') + mqtt.state = 'busy' + aprs.request_to_send = 1; + else: + mqtt.state = 'busy' + +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() + + # Send ready to MQTT broker to indicate we are meaning business + topic = mqtt.topic_root + '/aprs_status' + publish(client,topic,'ready') + mqtt.state = 'ready' + + aprs.time_out_timer = time.time() + while True: + + if aprs.request_to_send == 1: + send_ax25(aprs.port_call, aprs.source_call, mqtt.destination, 0, aprs.message) + aprs.time_out_timer = time.time() # Restart timeout timer + #print(aprs.selected_port) + #print(aprs.message) + aprs.request_to_send = 0 + + 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("") + + if aprs.wait_for_ack != 0: + if source == aprs.call_of_wait_for_ack: + # split payload at colon. If it is a valid acknowledge, we should get three + # substrings: the first in empty, the second with the call of the ax25 + # interface and the thirth with the acknowledge + + split_message=payload.split(":") + if len(split_message) == 3: + if split_message[1].replace(" ", "") == axaddress[port]: + if split_message[2] == aprs.wait_for_ack: + print ('Received acknowledge ' + aprs.wait_for_ack + ' from ' + aprs.call_of_wait_for_ack + ".") + aprs.time_out_timer = time.time() # Restart timeout timer + aprs.wait_for_ack = 0 + aprs.busy = 0 + aprs.retry_counter = 0 + topic = mqtt.topic_root + '/aprs_status' + publish(client,topic,'ready') + mqtt.state = 'ready' + + + # Time out waiting for acknowledge + if aprs.wait_for_ack != 0: + if time.time() - aprs.time_out_timer > mqtt.transmit_rate: + aprs.retry_counter = aprs.retry_counter + 1 + if aprs.retry_counter < mqtt.retry: + # Try again + aprs.time_out_timer = time.time() # Restart timeout timer + aprs.request_to_send = 1; + #os.system(aprs.beacon_program_with_arguments) + print ('Retry: APRS ' + aprs.message + ' message send to ' + aprs.call_of_wait_for_ack + '.') + else: + # Give up + print ('No acknowledge received from ' + aprs.call_of_wait_for_ack + '. Giving up.') + aprs.time_out_timer = time.time() # Restart timeout timer + aprs.wait_for_ack = 0 + aprs.busy = 0 + aprs.retry_counter = 0 + topic = mqtt.topic_root + '/aprs_status' + publish(client,topic,'ready') + mqtt.state = 'ready' + # If APRS system is still waiting for acknowledge, keep on waiting and send an MQTT update + if mqtt.state == 'busy': + topic = mqtt.topic_root + '/aprs_status' + publish(client,topic,'busy') + mqtt.state = 'ready' + + +if __name__ == '__main__': + #sys.stdout = sys.stderr = open('debug.log', 'w') + run() + diff --git a/aprs-mqtt-bridge.yml b/aprs-mqtt-bridge.yml new file mode 100644 index 0000000..03d4b88 --- /dev/null +++ b/aprs-mqtt-bridge.yml @@ -0,0 +1,77 @@ +# Global settings apply to all other entries +global: + broker: pe1rxf.ampr.org # The MQTT broker we are going to use + port: 1883 # The tcp port of the MQTT broker + topic_root: hamnet_aprs_nodes # MQTT topic root + transmit_rate: 20 # Number of seconds between each transmision + retry: 3 # Try this often before giving up + destination: APRX29 # Destination or program ID + digi_path: WIDE2-1 # Digi path of APRS messages + #beacon_program: /usr/sbin/beacon # The external AX.25 beacon program => obsolete + + +topics: + # MQTT topic: 5GHz dish at workshop (must be unique name) + - name: ubiquity_dish_ptmp_workshop + call: PE1RXF-6 # Call of node to which commands below are send + server: PE1RXF-3 # Call of APRS server sending the commands + port: ax2 # Name of AX.25 port to use + command: + - payload: 'ON' # This is the payload we have to receive + cmd: 33{33 # This command is send to the node + response: ack33 # This response is expected from the node + - payload: 'OFF' + cmd: 32{32 + response: ack32 + + # Server at workshop + - name: server_workshop + call: PE1RXF-6 + server: PE1RXF-3 + port: ax2 + command: + - payload: 'ON' + cmd: 31{31 + response: ack31 + - payload: 'OFF' + cmd: 30{30 + response: ack30 + + # MQTT topic: 5GHz dish at tiny house + - name: ubiquity_dish_ptp_tiny_house + call: PE1RXF-5 + server: PE1RXF-3 + port: ax2 + command: + - payload: 'ON' + cmd: 35{35 + response: ack35 + - payload: 'OFF' + cmd: 34{34 + response: ack34 + + # MQTT topic: 5GHz dish in orchard + - name: ubiquity_dish_ptmp_orchard + call: PE1RXF-8 + server: PE1RXF-3 + port: ax2 + command: + - payload: 'ON' + cmd: 31{31 + response: ack31 + - payload: 'OFF' + cmd: 30{30 + response: ack30 + + # MQTT topic: 5GHz dish in vegetable garden + - name: ubiquity_dish_ptp_vegetable_garden + call: PE1RXF-8 + server: PE1RXF-3 + port: ax2 + command: + - payload: 'ON' + cmd: 33{33 + response: ack33 + - payload: 'OFF' + cmd: 32{32 + response: ack32 diff --git a/python-ax25/README.md b/python-ax25/README.md new file mode 100644 index 0000000..f39cfea --- /dev/null +++ b/python-ax25/README.md @@ -0,0 +1,115 @@ +# python-ax25 + +Python AX.25 Module for Python3 + +## Introduction + +This is a python module designed for Python3 to access AX.25 features. This module is a C extension that can access the AX.25 interface from the Linux kernel. + +This C extension is inspired from pyax25 https://github.com/ha5di/pyax25 + +## Installing the Module + +Clone the Git repository + +``` +$ git clone https://github.com/josefmtd/python-ax25 +``` + +Install the module by running the install script inside the python-ax25 directory + +``` +$ cd python-ax25 +# ./install.sh +``` + +## Module Functions + +Before using any of the functions, make sure to load all the available ports using `config_load_ports()` + +``` +pythonax25.config_load_ports() + +Returns = number of available ports (int) +``` + +To get the names of available ports, use the `config_get_first_port` and `config_get_next_port` + +``` +pythonax25.config_get_first_port() + +Returns = name of first port (unicode string) + + +pythonax25.config_get_next_port(portname) + +Returns = name of port after 'portname' (unicode string) +``` + +To retrieve further information for each available port, use these functions: +1. `config_get_port_name(device)` +2. `config_get_address(portname)` +3. `config_get_device(portname)` +4. `config_get_window(portname)` +5. `config_get_packet_length(portname)` +6. `config_get_baudrate(portname)` +7. `config_get_description(portname)` + +To change the callsign from ASCII to network format and vice versa, use the functions `ascii_to_network(callsignascii)` and `network_to_ascii(callsignnetwork)` + +``` +pythonax25.ascii_to_network(callsignascii) + +Returns = callsign in network format (byte literal string) + + +pythonax25.network_to_ascii(callsignnetwork) + +Returns = callsign in ascii format (unicode string) +``` + +For receiving AX.25 packets, the packet socket is mostly used in C programs. Start a socket by using `packet_socket()` and begin receiving by using `packet_rx(fd, timeout)` + +``` +pythonax25.packet_socket() + +Returns = file descriptor (int) + + +pythonax25.packet_rx(fd, timeout) + +Returns = Protocol and Address (tuple of int and string) and packet (byte-literal string) +``` + +For sending APRS messages, the datagram socket is used. Socket is started by using `datagram_socket()`, bound to a port by using `datagram_bind(fd, srccall, portcall)` and send packets via `datagram_tx(fd, destcall, message)` or `datagram_tx(fd, destcall, digicall, message)` + +``` +pythonax25.datagram_socket() + +Returns = file descriptor (int) + + +pythonax25.datagram_bind(fd, srccall, destcall) + +Returns = result of bind (int) + + +pythonax25.datagram_tx(fd, destcall, message) + +Returns = result of transmission (int) + + +pythonax25.datagram_tx_digi(fd, destcall, digicall, message) + +Returns = result of transmission (int) +``` + +Closing socket is done by using `close_socket(fd)` + +``` +pythonax25.close_socket(fd) + +Returns = result of close (int) +``` + +2020 - Josef Matondang diff --git a/python-ax25/examples/readAPRS.py b/python-ax25/examples/readAPRS.py new file mode 100755 index 0000000..3239997 --- /dev/null +++ b/python-ax25/examples/readAPRS.py @@ -0,0 +1,65 @@ +#!/usr/bin/python3 + +import pythonax25 + +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] is 0: + # Cut the first byte and feed it to the address parser + listAddress = getAllAddress(address[1:]) + + # Get the source, destination, and digipeaters from the address list + source = listAddress[1] + destination = listAddress[0] + digipeaters = listAddress[2:] + else: + raise Exception('Not a data packet') + + payload = buffer[1] + return (source, destination, digipeaters, payload) + +def getAllAddress(packetAddress): + addressSize = 7 + # Check if the networked address string is valid + if (len(packetAddress) % 7) is 0: + # Create a list of all address in ASCII form + allAddress = [pythonax25.network_to_ascii(packetAddress[i:i+addressSize]) + for i in range(0, len(packetAddress), addressSize)] + return allAddress + else: + raise Exception('Error: Address is not a multiple of 7') + +def main(): + # Check if there's any active AX25 port + if pythonax25.config_load_ports() > 0: + # Get the device name of the first port + axport = pythonax25.config_get_first_port() + axdevice = pythonax25.config_get_device(axport) + axaddress = pythonax25.config_get_address(axport) + else: + exit(0) + + # Initiate a PF_PACKET socket + socket = pythonax25.packet_socket() + + while True: + # Blocking receive packet, 10 ms timeout + receive = pythonax25.packet_rx(socket,10) + if receive[0][1] == axdevice: + print(receive) + source, destination, digipeaters, payload = parsePacket(receive[1]) + print("Packet Received by = %s"%axaddress) + print("Source Address = %s"%source) + print("Destination Address = %s"%destination) + print("Digipeaters =") + print(digipeaters) + print("Payload = %s"%payload) + print("") + else: + continue + +main() diff --git a/python-ax25/examples/sendAPRS.py b/python-ax25/examples/sendAPRS.py new file mode 100755 index 0000000..914c4b4 --- /dev/null +++ b/python-ax25/examples/sendAPRS.py @@ -0,0 +1,49 @@ +#!/usr/bin/python3 + +import pythonax25 +import time + +def main(): + # Check if there's any active AX25 port + if pythonax25.config_load_ports() > 0: + # Get the device name of the first port + axport = pythonax25.config_get_first_port() + axdevice = pythonax25.config_get_device(axport) + axaddress = pythonax25.config_get_address(axport) + else: + exit(0) + + # Initiate a datagram socket + socket = pythonax25.datagram_socket() + + srcCall = 'YD0ABH-13' + portCall = axaddress + + res = pythonax25.datagram_bind(socket, srcCall, portCall) + print(res) + + dest = 'APZINA' + digi = 'WIDE2-2' + msg = '!0611.08S/10649.35E$ INARad LoRa APRS#CO2=500' + + res = pythonax25.datagram_tx_digi(socket, dest, digi, msg) + print(res) + + time.sleep(1) + + msg = 'T#001,034,034,034,034,000,11111111' + res = pythonax25.datagram_tx_digi(socket, dest, digi, msg) + print(res) + + time.sleep(1) + + msg = '_07190749c045s055g055t076r001h45b10101' + res = pythonax25.datagram_tx_digi(socket, dest, digi, msg) + print(res) + + pythonax25.close_socket(socket) + + return res + +if __name__ == '__main__': + main() diff --git a/python-ax25/install.sh b/python-ax25/install.sh new file mode 100755 index 0000000..2cb70fe --- /dev/null +++ b/python-ax25/install.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +DIR=`dirname $0` + +# Update the system +#/usr/bin/apt update +#/usr/bin/apt -y upgrade + +# Install the dependencies +#/usr/bin/apt -y install libax25 libax25-dev ax25-apps ax25-tools python3-dev + +# Install the Python module +${DIR}/setup.py build +${DIR}/setup.py install + +# Remove the build +/bin/rm -rf "${DIR}/build" diff --git a/python-ax25/pythonax25module.c b/python-ax25/pythonax25module.c new file mode 100644 index 0000000..acc77ad --- /dev/null +++ b/python-ax25/pythonax25module.c @@ -0,0 +1,352 @@ +#define PY_SSIZE_T_CLEAN +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// #include + +#include +#include +#include + +#include +#include +#include + +static PyObject * config_load_ports(PyObject* self, PyObject* args) { + int activePort; + activePort = ax25_config_load_ports(); + return PyLong_FromLong(activePort); +} + +static PyObject * config_get_first_port(PyObject* self, PyObject* args) { + char *portName; + portName = ax25_config_get_next(NULL); + return Py_BuildValue("s", portName); +} + +static PyObject * config_get_next_port(PyObject* self, PyObject* args) { + char *portName; + char *nextPort; + PyArg_ParseTuple(args, "s", &portName); + nextPort = ax25_config_get_next(portName); + return Py_BuildValue("s", nextPort); +} + +static PyObject * config_get_port_name(PyObject* self, PyObject* args) { + char *device; + char *portName; + PyArg_ParseTuple(args, "s", &device); + portName = ax25_config_get_name(device); + return Py_BuildValue("s", portName); +} + +static PyObject * config_get_address(PyObject* self, PyObject* args) { + char *portName; + char *address; + PyArg_ParseTuple(args, "s", &portName); + address = ax25_config_get_addr(portName); + return Py_BuildValue("s", address); +} + +static PyObject * config_get_device(PyObject* self, PyObject* args) { + char *device; + char *portName; + PyArg_ParseTuple(args, "s", &portName); + device = ax25_config_get_dev(portName); + return Py_BuildValue("s", device); +} + +static PyObject * config_get_window(PyObject* self, PyObject* args) { + int window; + char *portName; + PyArg_ParseTuple(args, "s", &portName); + window = ax25_config_get_window(portName); + return PyLong_FromLong(window); +} + +static PyObject * config_get_packet_length(PyObject* self, PyObject* args) { + int packetLength; + char *portName; + PyArg_ParseTuple(args, "s", &portName); + packetLength = ax25_config_get_paclen(portName); + return PyLong_FromLong(packetLength); +} + +static PyObject * config_get_baudrate(PyObject* self, PyObject* args) { + int baudRate; + char *portName; + PyArg_ParseTuple(args, "s", &portName); + baudRate = ax25_config_get_baud(portName); + return Py_BuildValue("i", baudRate); +} + +static PyObject * config_get_description(PyObject* self, PyObject* args) { + char *description; + char *portName; + PyArg_ParseTuple(args, "s", &portName); + description = ax25_config_get_desc(portName); + return Py_BuildValue("s", description); +} + +static PyObject * aton_entry(PyObject* self, PyObject* args) { + char *callsignNetwork = null_ax25_address.ax25_call; + char *callsignString; + int result; + + PyArg_ParseTuple(args, "s", &callsignString); + result = ax25_aton_entry(callsignString, callsignNetwork); + return Py_BuildValue("iy", result, callsignNetwork); +} + +static PyObject * ntoa(PyObject* self, PyObject* args) { + static PyObject * callsignPython; + char *callsignNetwork; + char *callsignString; + + ax25_address *callsign = &null_ax25_address; + + if (!PyArg_ParseTuple(args, "y", &callsignNetwork)) + fprintf(stderr, "ERROR: CANNOT ASSIGN\n"); + + strncpy(callsign->ax25_call, callsignNetwork, 7); + callsignString = ax25_ntoa(callsign); + + callsignPython = Py_BuildValue("s", callsignString); + + return callsignPython; +} + +static PyObject * datagram_socket(PyObject* self, PyObject* args) { + int fileDescriptor; + fileDescriptor = socket(AF_AX25, SOCK_DGRAM, 0); + return PyLong_FromLong(fileDescriptor); +} + +static PyObject * datagram_bind(PyObject* self, PyObject* args) { + struct full_sockaddr_ax25 src; + char *portcall, *srccall; + int len, sock, result; + + PyArg_ParseTuple(args, "iss", &sock, &srccall, &portcall); + + char * addr = malloc(sizeof(char*) * (strlen(srccall) + strlen(portcall) + 2)); + sprintf(addr, "%s %s", srccall, portcall); + + len = ax25_aton(addr, &src); + + free(addr); + + // Binding the socket to source + if (bind(sock, (struct sockaddr *)&src, len) == -1) { + result = 1; + } + else { + result = 0; + } + + return PyLong_FromLong(result); + +} + +static PyObject * datagram_tx_digi(PyObject* self, PyObject* args) { + struct full_sockaddr_ax25 dest; + char *destcall = NULL, *digicall = NULL; + char *message; + int dlen, sock, result; + + PyArg_ParseTuple(args, "isss", &sock, &destcall, &digicall, &message); + + char * addr = malloc(sizeof(char*) * (strlen(destcall) + strlen(digicall) + 2)); + sprintf(addr, "%s %s", destcall, digicall); + + dlen = ax25_aton(addr, &dest); + + free(addr); + + // Send a datagram packet to socket + if (sendto(sock, message, strlen(message), 0, (struct sockaddr *)&dest, dlen) == -1) { + result = 1; + } + + result = 0; + return PyLong_FromLong(result); +} + +static PyObject * datagram_tx(PyObject* self, PyObject* args) { + struct full_sockaddr_ax25 dest; + char *destcall = NULL; + char *message; + int dlen, sock, result; + + PyArg_ParseTuple(args, "iss", &sock, &destcall, &message); + + dlen = ax25_aton(destcall, &dest); + + // Send a datagram packet to socket + if (sendto(sock, message, strlen(message), 0, (struct sockaddr *)&dest, dlen) == -1) { + result = 1; + } + + result = 0; + return PyLong_FromLong(result); +} + +// Using PF_PACKET Socket + +static PyObject * packet_socket(PyObject* self, PyObject* args) { + int fileDescriptor; + fileDescriptor = socket(PF_PACKET, SOCK_PACKET, htons(ETH_P_AX25)); + return PyLong_FromLong(fileDescriptor); +} + +// Close a socket +static PyObject * close_socket(PyObject* self, PyObject* args) { + int fileDescriptor; + int result; + + PyArg_ParseTuple(args, "i", &fileDescriptor); + result = close(fileDescriptor); + return PyLong_FromLong(result); +} + +static PyObject * packet_tx(PyObject* self, PyObject* args) { + int fileDescriptor; + int result; + int length; + char *buffer; + char *destination; + struct sockaddr socketAddress; + int addressSize = sizeof(socketAddress); + unsigned char newBuffer[1000]; + int bufferLength; + int i; + int k; + unsigned char charBuffer; + + PyArg_ParseTuple(args, "isis", &fileDescriptor, &buffer, &length, &destination); + + bufferLength = strlen(buffer); + + i = 0; + k = 0; + + while ( i < bufferLength ) { + charBuffer = (buffer[i++] & 0x0f) << 4; + charBuffer = charBuffer | (buffer[i++] & 0x0f); + newBuffer[k++] = charBuffer; + } + + strcpy(socketAddress.sa_data, destination); + socketAddress.sa_family = AF_AX25; + + result = sendto(fileDescriptor, newBuffer, k, 0, &socketAddress, addressSize); + + return Py_BuildValue("i", result); +} + +static PyObject * packet_rx(PyObject* self, PyObject* args) { + int fileDescriptor; + int result; + int addressSize; + int packetSize; + int timeout; + + struct sockaddr socketAddress; + struct pollfd pollFileDescriptor; + + unsigned char receiveBuffer[1024]; + + PyArg_ParseTuple(args, "ii", &fileDescriptor, &timeout); + + // Poll the socket for an available data + pollFileDescriptor.fd = fileDescriptor; + pollFileDescriptor.events = POLLRDNORM; + + result = poll(&pollFileDescriptor, 1, timeout); + + // Read all packet received + packetSize = 0; + socketAddress.sa_family = AF_UNSPEC; + strcpy(socketAddress.sa_data, ""); + + if (result == 1) { + addressSize = sizeof(socketAddress); + packetSize = recvfrom(fileDescriptor, receiveBuffer, sizeof(receiveBuffer), + 0, &socketAddress, (socklen_t*)&addressSize); + } + + return Py_BuildValue("(is)y#", socketAddress.sa_family, socketAddress.sa_data, + receiveBuffer, packetSize); +} + +static PyObject *PythonAx25Error; + +////////////////////////////////////////// +// Define methods +////////////////////////////////////////// + +static PyMethodDef python_ax25_functions[] = { + {"config_load_ports", config_load_ports, METH_VARARGS, ""}, + {"config_get_first_port", config_get_first_port, METH_VARARGS, ""}, + {"config_get_next_port", config_get_next_port, METH_VARARGS, ""}, + {"config_get_port_name", config_get_port_name, METH_VARARGS, ""}, + {"config_get_address", config_get_address, METH_VARARGS, ""}, + {"config_get_device", config_get_device, METH_VARARGS, ""}, + {"config_get_window", config_get_window, METH_VARARGS, ""}, + {"config_get_packet_length", config_get_packet_length, METH_VARARGS, ""}, + {"config_get_baudrate", config_get_baudrate, METH_VARARGS, ""}, + {"config_get_description", config_get_description, METH_VARARGS, ""}, + {"network_to_ascii", ntoa, METH_VARARGS, ""}, + {"ascii_to_network", aton_entry, METH_VARARGS, ""}, + {"datagram_socket", datagram_socket, METH_VARARGS, ""}, + {"datagram_bind", datagram_bind, METH_VARARGS, ""}, + {"datagram_tx_digi", datagram_tx_digi, METH_VARARGS, ""}, + {"datagram_tx", datagram_tx, METH_VARARGS, ""}, + {"packet_socket", packet_socket, METH_VARARGS, ""}, + {"packet_rx", packet_rx, METH_VARARGS, ""}, + {"packet_tx", packet_tx, METH_VARARGS, ""}, + {"close_socket", close_socket, METH_VARARGS, ""}, + {NULL, NULL, 0, NULL} +}; + +// Initialize module +static struct PyModuleDef moduledef = { + PyModuleDef_HEAD_INIT, + "pythonax25", + "This is a python module for ax.25", + -1, + python_ax25_functions, + NULL, + NULL, + NULL, + NULL, +}; + +PyMODINIT_FUNC + +PyInit_pythonax25(void) { + PyObject * m; + m = PyModule_Create(&moduledef); + + if (m == NULL) + return NULL; + + PythonAx25Error = PyErr_NewException("pythonax25.error", NULL, NULL); + Py_INCREF(PythonAx25Error); + PyModule_AddObject(m, "error", PythonAx25Error); + + return m; +} diff --git a/python-ax25/setup.py b/python-ax25/setup.py new file mode 100755 index 0000000..ed0ffe3 --- /dev/null +++ b/python-ax25/setup.py @@ -0,0 +1,10 @@ +#!/usr/bin/python3 + +from distutils.core import setup, Extension + +module1 = Extension('pythonax25', libraries = ['ax25', 'ax25io'], sources = ['pythonax25module.c']) + +setup (name = 'pythonax25', + version = '1.0', + description = 'CPython extension for LINUX ax.25 stack', + ext_modules = [module1])