From 711bf9f7dcf03f500d50e05f2e7794f1f13e22c7 Mon Sep 17 00:00:00 2001 From: marcel Date: Sun, 18 Feb 2024 18:22:51 +0100 Subject: [PATCH] First commit --- CHANGELOG.md | 40 ++ README.md | 21 + ax25.py | 36 ++ config_reader.py | 160 ++++++ pe1rxf_aprs.py | 490 ++++++++++++++++++ pe1rxf_aprs.yml | 89 ++++ pe1rxf_telemetry.yml | 5 + python-ax25/README.md | 115 ++++ .../pythonax25-1.0-py3.11-linux-armv7l.egg | Bin 0 -> 18480 bytes python-ax25/examples/readAPRS.py | 65 +++ python-ax25/examples/sendAPRS.py | 49 ++ python-ax25/install.sh | 17 + python-ax25/pythonax25.egg-info/PKG-INFO | 4 + python-ax25/pythonax25.egg-info/SOURCES.txt | 7 + .../pythonax25.egg-info/dependency_links.txt | 1 + python-ax25/pythonax25.egg-info/top_level.txt | 1 + python-ax25/pythonax25module.c | 352 +++++++++++++ python-ax25/setup.py | 10 + start_weater_station.sh | 5 + weather_station_modbus.py | 134 +++++ 20 files changed, 1601 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 ax25.py create mode 100644 config_reader.py create mode 100644 pe1rxf_aprs.py create mode 100644 pe1rxf_aprs.yml create mode 100644 pe1rxf_telemetry.yml create mode 100644 python-ax25/README.md create mode 100644 python-ax25/dist/pythonax25-1.0-py3.11-linux-armv7l.egg 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/pythonax25.egg-info/PKG-INFO create mode 100644 python-ax25/pythonax25.egg-info/SOURCES.txt create mode 100644 python-ax25/pythonax25.egg-info/dependency_links.txt create mode 100644 python-ax25/pythonax25.egg-info/top_level.txt create mode 100644 python-ax25/pythonax25module.c create mode 100755 python-ax25/setup.py create mode 100755 start_weater_station.sh create mode 100644 weather_station_modbus.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cebeea9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# 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. + +## [0.0.1] - pre 2024-02-16 + +- Had only weather station functionality + +## [0.1.0] - 2024-02-16 + +### Added + +- Can now send beacons +- Beacons and weather reports transmit intervals, digi_paths, etc. can be set via a YAML configuration file +- Periodic transmisions are scheduled with +/- one minute randomnes in transmision time +- APRS frames are logged to file (file name can be set in YAML configuration file) +- sending to APRS-IS implemented + +## [0.1.1] - 2024-02-17 + +### Fixed + +- Catch errors when connection to APRS-IS fails, so program doesn't crash. + +### Added + +- connected to APRS-IS feed + +## [0.1.1] - 2024-02-17 + +### Added + +- iGate functionality diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b6cd94 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# PE1RXF APRS + +A basic Linux APRS iGate and APRS weather station with additional (optional) PE1RXF telemetry support. + +## Requirements + +- Python3 +- minimalmodbus +- retrying +- gps3 +- schedule +- aprslib + +## License + +Copyright (C) 2024 M.T. Konstapel + +This program 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. diff --git a/ax25.py b/ax25.py new file mode 100644 index 0000000..f032f7e --- /dev/null +++ b/ax25.py @@ -0,0 +1,36 @@ +''' +# A basic APRS iGate and APRS weather station with additional (optional) PE1RXF telemetry support +# +# This program reads the registers of the PE1RXF weather station via ModBus RTU and sends it as +# an APRS WX report over APRS. Additionally, it sends beacons and forwards received APRS messages +# to the APRS-IS network. All configurable via a YAML file called pe1rxf_aprs.yml. +# +# This program also has a PE1RXF APRS telemetry to MQTT bridge, which is configurable via pe1rxf_telemetry.yml +# +# Copyright (C) 2023, 2024 M.T. Konstapel https://meezenest.nl/mees +# +# This file is part of weather_station +# +# weather_station 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. +# +# weather_station 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 weather_station. If not, see . +''' + +import yaml +from yaml.loader import SafeLoader + +class config_reader: + + # initiate class: define name configuration files + def __init__(self, main_config_file, telemetry_config_file): + return 0 + diff --git a/config_reader.py b/config_reader.py new file mode 100644 index 0000000..3b1a776 --- /dev/null +++ b/config_reader.py @@ -0,0 +1,160 @@ +''' +# A basic APRS iGate and APRS weather station with additional (optional) PE1RXF telemetry support +# +# This program reads the registers of the PE1RXF weather station via ModBus RTU and sends it as +# an APRS WX report over APRS. Additionally, it sends beacons and forwards received APRS messages +# to the APRS-IS network. All configurable via a YAML file called pe1rxf_aprs.yml. +# +# This program also has a PE1RXF APRS telemetry to MQTT bridge, which is configurable via pe1rxf_telemetry.yml +# +# Copyright (C) 2023, 2024 M.T. Konstapel https://meezenest.nl/mees +# +# This file is part of weather_station +# +# weather_station 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. +# +# weather_station 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 weather_station. If not, see . +''' + +import yaml +from yaml.loader import SafeLoader + +class config_reader: + + # initiate class: define name configuration files + def __init__(self, main_config_file, telemetry_config_file): + self.config_file = main_config_file + self.telemetry_file = telemetry_config_file + self.modbus_settings = [] + self.aprsis_settings = [] + self.weather_settings = [] + self.beacon_settings = [] + self.telemetry_settings = [] + + def read_settings(self): + + if self.read_config_file() == 0: + return 0 + + if self.read_telemetry_file() == 0: + return 0 + + if self.test_global_settings() == 0: + return 0 + + if self.test_modbus_settings() == 0: + return 0 + + if self.test_aprsis_settings() == 0: + return 0 + + if self.test_weather_settings() == 0: + return 0 + + if self.test_beacon_settings() == 0: + return 0 + + if self.test_telemetry_settings() == 0: + return 0 + return 1 + + + def read_config_file (self): + try: + with open(self.config_file) as f: + self.config_file_settings = yaml.load(f, Loader=SafeLoader) + except: + print ("Configuration file ./" + self.config_file + " not found or syntax error in file.") + return 0 + else: + return 1 + + def read_telemetry_file (self): + try: + with open(self.telemetry_file) as f: + self.telemetry_settings = yaml.load(f, Loader=SafeLoader) + except: + print ("Telemetry configuration file ./" + self.telemetry_file + " not found or syntax error in file.") + return 0 + else: + return 1 + + + # Test if all settings are pressebt + def test_global_settings(self): + # Test is all expected settings are present + try: + tmp = self.config_file_settings['global']['log-rf'] + except: + print ("Error in the global section of the configuration file.") + return 0 + else: + return 1 + + # Test if all settings are pressebt + def test_modbus_settings(self): + # Test is all expected settings are present + try: + tmp = self.config_file_settings['modbus']['port'] + tmp = self.config_file_settings['modbus']['address'] + except: + print ("Error in the modbus section of the configuration file.") + return 0 + else: + return 1 + + def test_aprsis_settings(self): + try: + tmp = self.config_file_settings['aprsis']['call'] + tmp = self.config_file_settings['aprsis']['passcode'] + tmp = self.config_file_settings['aprsis']['server'] + tmp = self.config_file_settings['aprsis']['port'] + tmp = self.config_file_settings['aprsis']['filter'] + except: + print ("Error in the aprsis section of the configuration file.") + return 0 + else: + return 1 + + def test_weather_settings(self): + for entry in self.config_file_settings['weather']: + try: + tmp = entry['port'] + tmp = entry['call'] + tmp = entry['destination'] + tmp = entry['digi_path'] + tmp = entry['position'] + tmp = entry['interval'] + except: + print ("Error in the weather section of the configuration file.") + return 0 + else: + return 1 + + def test_beacon_settings(self): + for entry in self.config_file_settings['beacon']: + try: + tmp = entry['port'] + tmp = entry['call'] + tmp = entry['destination'] + tmp = entry['digi_path'] + tmp = entry['position'] + tmp = entry['interval'] + tmp = entry['message'] + except: + print ("Error in the beacon section of the configuration file.") + return 0 + else: + return 1 + + def test_telemetry_settings(self): + return 1 diff --git a/pe1rxf_aprs.py b/pe1rxf_aprs.py new file mode 100644 index 0000000..0e87304 --- /dev/null +++ b/pe1rxf_aprs.py @@ -0,0 +1,490 @@ +''' +# A basic APRS iGate and APRS weather station with additional (optional) PE1RXF telemetry support +# +# This program reads the registers of the PE1RXF weather station via ModBus RTU and sends it as +# an APRS WX report over APRS. Additionally, it sends beacons and forwards received APRS messages +# to the APRS-IS network. All configurable via a YAML file called pe1rxf_aprs.yml. +# +# This program also has a PE1RXF APRS telemetry to MQTT bridge, which is configurable via pe1rxf_telemetry.yml +# +# Copyright (C) 2023, 2024 M.T. Konstapel https://meezenest.nl/mees +# +# This file is part of weather_station +# +# weather_station 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. +# +# weather_station 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 weather_station. If not, see . +''' +import sys +import time +from time import gmtime, strftime +import pythonax25 +import yaml +from yaml.loader import SafeLoader +import schedule +import aprslib +import logging +from threading import Thread + +from weather_station_modbus import WeatherStation +from config_reader import config_reader + +main_config_file = "pe1rxf_aprs.yml" +telemetry_config_file = "pe1rxf_telemetry.yml" +rflog_file = "" +# Make Weather data global so scheduled task can use it +WxData = [] +APRSIS = [] + +axport = [] +axdevice = [] +axaddress = [] +class aprs_status: + nr_of_ports = 0 + pass +aprs = aprs_status() + +# AX.25 stuff +def setup_ax25(): + # Check if there's any active AX25 port + print("\nAvailable AX.25 ports:") + 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: + print("No AX.25 ports found.") + sys.exit() + + # Initiate a PF_PACKET socket (RX) + rx_socket = pythonax25.packet_socket() + + return rx_socket + +def setup_old__ax25(): + # Check if there's any active AX25 port + print("\nAvailable AX.25 ports:") + 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: + print("No AX.25 ports found.") + sys.exit() + + # 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 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:] + # Occasionally a bad packet is received causng the program to crash with an "IndexError: list index out of range". Fix: check if index IS out of range before copying it to payload + if len(buffer) > 1: + payload = buffer[1] + else: + 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' + 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): + allAddress = [] + addressSize = 7 + + # Check if the networked address string is valid + if (len(packetAddress) % 7) == 0: + + for i in range(0, len(packetAddress), addressSize): + address = "" + # First extract CALL + for pos in range(6): + address = address + ( chr(packetAddress[i+pos]>>1) ) + + # Remove spaces + address= address.rstrip() + # Than extract SSID + + ssid = packetAddress[i+6] & 0b00011110 + ssid = ssid >> 1 + if ssid != 0: + address = address + '-' + str(ssid) + + # Has been repeated flag set? Check only for digipeaters, not source and destination + if i != 0 and i != 7: + if packetAddress[i+6] & 0b10000000: + address = address + '*' + #print (address) + + allAddress.append(address) + + # 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 + #print (allAddress) + 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 process_aprsis(packet): + + #print("Received APRSIS") + timestamp = time.strftime("%Y-%m-%d %H:%M:%S", gmtime()) + + if rflog_file == 0: + return 0 + + string = timestamp + ' ' + 'APRSIS ' + ' R ' + str(packet, 'utf-8') + '\n' + + try: + with open(rflog_file, "a") as logfile: + logfile.write(string) + except: + return 0 + else: + return 1 + +def send_aprsis(srcCall, dest, digi, msg): + try: + APRSIS.connect() + except: + print("Could not connect to APRS-IS network.") + else: + if digi == 0 or digi == "": + message = srcCall + '>' + dest + ':' + msg + else: + # Drop those packets we shouldn't send to APRS-IS + if (',TCP' in digi): # drop packets sourced from internet + print(">>> Internet packet not igated: " + packet) + return 1 + if ('RFONLY' in digi): + print(">>> RFONLY, not igated.") + return 1 + if ('NOGATE' in digi): + print(">>> NOGATE, not igated") + return 1 + + message = srcCall + '>' + dest + ',' + digi + ':' + msg + + # send a single status message + try: + APRSIS.sendall(message) + #print(message) + except: + print("Failed to send message to APRS-IS network.") + else: + log_ax25("APRSIS ", srcCall, dest, digi, msg, 'T') + +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 or digi == "": + res = pythonax25.datagram_tx(tx_socket, dest, msg) + #print(timestamp + ' ' + portCall + ' T ' + srcCall + '>' + dest + ':' + msg) + else: + res = pythonax25.datagram_tx_digi(tx_socket, dest, digi, msg) + #print(timestamp + ' ' + portCall + ' T ' + srcCall + '>' + dest + ',' + digi + ':' + msg) + + #print(res) + pythonax25.close_socket(tx_socket) + + log_ax25(portCall, srcCall, dest, digi, msg, 'T') + +# If logging is enabled in configuration file (global:log-rf), append APRS frame to log file +def log_ax25(portCall, srcCall, dest, digi, msg, mode): + timestamp = time.strftime("%Y-%m-%d %H:%M:%S", gmtime()) + + if rflog_file == 0: + return 0 + + if digi == 0 or digi == "": + string = timestamp + ' ' + portCall.ljust(9) + ' ' + mode + ' ' + srcCall + '>' + dest + ':' + msg + '\n' + else: + string = timestamp + ' ' + portCall.ljust(9) + ' ' + mode + ' ' + srcCall + '>' + dest + ',' + digi + ':' + msg + '\n' + + try: + with open(rflog_file, "a") as logfile: + logfile.write(string) + except: + return 0 + else: + return 1 + +def send_aprs_weather_report(weather_settings): + global WxData + + # Convert sensible SI values to freedom units for APRS weather report + wind_direction = int(WxData['Wind direction']) + wind_speed = int(2.2369 * WxData['Wind speed']) + wind_gust = int(2.2369 * WxData['Wind gust']) + #rain_lasthour = int(3.93700787 * WxData['Rain last hour']) + rain_lasthour = 0 + #rain_24hour = int(3.93700787 * WxData['Rain last 24 hours']) + rain_24hour = 0 + temperature = int(WxData['Temperature'] * 1.8 + 32) + humidity = int(WxData['Humidity']) + if (humidity == 100): + humidity = 0; + pressure =int(10 * WxData['Pressure']) + + # Get date and time + timestamp = time.strftime("%d%H%M", gmtime()) + + # Construct APRS weather report + aprs_position = weather_settings['position'] + aprs_wx_report = '@' + timestamp + 'z' + weather_settings['position'] + "{:03d}".format(wind_direction) + '/' + "{:03d}".format(wind_speed) + 'g' + "{:03d}".format(wind_gust) + 't' + "{:03d}".format(temperature) + 'r' + "{:03d}".format(rain_lasthour) + 'p' + "{:03d}".format(rain_24hour) + 'h' + "{:02d}".format(humidity) + 'b' + "{:05d}".format(pressure) + + if weather_settings['port'] == 'aprsis': + send_aprsis(weather_settings['call'], weather_settings['destination'], weather_settings['digi_path'], aprs_wx_report) + else: + # Send it + # Find call of ax25 port + port_call = 0 + for position in range(len(axdevice)): + if axdevice[position] == weather_settings['port']: + port_call = axaddress[position] + + if (weather_settings['call'] == 0): + src_call = port_call + else: + src_call = weather_settings['call'] + + send_ax25(port_call, src_call, weather_settings['destination'], weather_settings['digi_path'], aprs_wx_report) + +def send_aprs_beacon(beacon_settings): + beacon_message = beacon_settings['position'] + beacon_settings['message'] + + if beacon_settings['port'] == 'aprsis': + send_aprsis(beacon_settings['call'], beacon_settings['destination'], beacon_settings['digi_path'], beacon_message) + else: + # Send it + # Find call of ax25 port + port_call = 0 + for position in range(len(axdevice)): + if axdevice[position] == beacon_settings['port']: + port_call = axaddress[position] + + if (beacon_settings['call'] == 0): + src_call = port_call + else: + src_call = beacon_settings['call'] + + send_ax25(port_call, src_call, beacon_settings['destination'], beacon_settings['digi_path'], beacon_message) + +def send_telemetry(): + message = ':' + 'PE1RXF-3 ' + ':' + str(WxData['Wind direction']) + ',' + str(WxData['Wind speed']) + ',' + str(WxData['Wind gust']) + ',' + str(WxData['Rain last hour']) + ',' + str(WxData['Rain last 24 hours']) + ',' + str(WxData['Temperature']) + ',' + str(WxData['Humidity']) + ',' + str(WxData['Pressure']) + ',' + str(WxData['Temp backup']) + ',' + str(WxData['Status bits']) + + send_ax25('PE1RXF-3', 'PE1RXF-13', "APZMDM", 0, message) + +def check_heater(weather_station): + # Check if heater is off, if so, turn it on + if weather_station.wx_data['Status bits'] & 0x4 == 0: + weather_station.enable_heater() + print("Heater was off, turned it back on gain.") + +def run(): + + global WxData + + # Debug logging for aprslib + logging.basicConfig(level=logging.DEBUG) # level=10 + + loop_counter=0 + + # Read the main configuration file (pe1rxf_aprs.yml) + Configuration = config_reader(main_config_file, telemetry_config_file) + + if Configuration.read_settings() == 0: + sys.exit() + + global rflog_file + rflog_file = Configuration.config_file_settings['global']['log-rf'] + + print ("Write APRS frames to: " + rflog_file) + print ("Read configuration files.") + + rx_socket = setup_ax25() + + # a valid passcode for the callsign is required in order to send + print("Connecting to APRS-IS server") + global APRSIS + APRSIS = aprslib.IS(Configuration.config_file_settings['aprsis']['call'], passwd=Configuration.config_file_settings['aprsis']['passcode'], port=Configuration.config_file_settings['aprsis']['port']) + APRSIS.connect() + + print("Trying to connect to the weather station via the RS-485 dongle...") + try: + weather_station = WeatherStation(Configuration.config_file_settings['modbus']['port'], Configuration.config_file_settings['modbus']['address']) + except: + print("No response from the weather station via the ModBus, even after several retries. Disabling the weather station.") + sys.exit(1) # Make program work without weather station + else: + print("Got response from the weather station. Weather information is available.") + + # NOTE: Should be done periodically! So when the weather station is unplugged and plugged back in, the heater will be enabled again. + try: + weather_station.enable_heater() + except: + print("No response from the weather station via the ModBus, even after several retries. Disabling the weather station.") + sys.exit(1) # Make program work without weather station! + else: + print("Enabled the heater function on the weather station.") + + ### + # Schedule all periodic transmissions (use a little randomness) + ### + # Schedule all weather report transmisions + for entry in Configuration.config_file_settings['weather']: + interval = entry['interval'] + if interval != 0: + print("Scheduled WX report transmission") + interval = interval * 60 # from minutes to seconds + schedule.every(interval - 59).to(interval + 59).seconds.do(send_aprs_weather_report, entry) + + # Schedule all beacon transmisions + for entry in Configuration.config_file_settings['beacon']: + interval = entry['interval'] + if interval != 0: + print("Scheduled beacon transmission.") + interval = interval * 60 # from minutes to seconds + schedule.every(interval - 59).to(interval + 59).seconds.do(send_aprs_beacon, entry) + + # Schedule telemetry transmision + print("Scheduled telemetry transmission.") + schedule.every(10).minutes.do(send_telemetry) + + # ScheduleL check if heater is still on + schedule.every(10).minutes.do(check_heater, weather_station) + + # Connect to incoming APRS-IS feed + # by default `raw` is False, then each line is ran through aprslib.parse() + + # Set filter on incomming feed + APRSIS.set_filter(Configuration.config_file_settings['aprsis']['filter']) + # This is a blocking call, should run as seperate thread + # create a thread + thread = Thread(target=APRSIS.consumer, args=(process_aprsis, True, True, True)) + # run the thread + thread.start() + + while (1): + + #print ("Reading registers of weather station.") + if weather_station.get_weather_data() == 0: + print ('No response from ModBus, even after 5 retries. Keep trying.') + else: + WxData = weather_station.wx_data + + schedule.run_pending() + + 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]) + #print(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("") + + #aprs_frame = axaddress[0] + '>' + destination + ',' + ','.join(digipeaters) + ':' + payload + #print (aprs_frame) + log_ax25(axaddress[port], source, destination, ','.join(digipeaters), payload, 'R') + digipeaters.append('qAR') + digipeaters.append(Configuration.config_file_settings['aprsis']['call']) + send_aprsis(source, destination, ','.join(digipeaters), payload) + #telemetry=process_message(source, port, payload, client) + + + #time.sleep(1) # Short sleep + + +if __name__ == '__main__': + sys.stdout = sys.stderr = open('/home/marcel/pe1rxf_aprs_debug.log', 'w') + + run() diff --git a/pe1rxf_aprs.yml b/pe1rxf_aprs.yml new file mode 100644 index 0000000..1e3c65f --- /dev/null +++ b/pe1rxf_aprs.yml @@ -0,0 +1,89 @@ +# This is the main configuration file for the PE1RXF APRS digipeater, igate and weather station software +# +# The settings for the PE1RXF telemetry server are configured in file pe1rxf_telemetry.yml +# +# NOTE: At the start, the program randomizes the starting time of every individual periodic transmission, +# so even if all intervals are equal, the transmissions are not at the same time, but rather spread over time. +# + +# This section (ax25) is being depricated +ax25: + call: PE1RXF-13 # Call from which transmissions are made + destination: APZMDM # APRS destination + + telemetry_port: ax1 # Linux AX.25 port to which telemetry is sent + telemetry_digi_path: 0 # Digipeater path for telemetry messages (0 = no path) + telemetry_interval: 5 # Time between telemetry transmissions + telemetry_server: PE1RXF-3 # PE1RXF telemetry server call + + weather_report_port: ax0 # Linux AX.25 port to which telemetry is sent + weather_report_digi_path: WIDE2-2 # Digipeater path for weather reports (0 = no path) + weather_report_interval: 10 # Time between weather report transmissions + +# Global settings +global: + log-rf: /home/marcel/test/pe1rxf_aprs-rf.log # Log RF traffic to file (0=no logging) + +modbus: + port: /dev/serial/by-path/platform-3f980000.usb-usb-0:1.1:1.0-port0 # USB port to which RS-485 dongle is connected + address: 14 # ModBus address of weather station + +# APRS-IS section +aprsis: + call: PE1RXF-1 # Amateur radio call + passcode: 19123 # APRS-IS passcode for the call + server: euro.aprs2.net # APRS-IS server to connect to (if set to 0 forwarding to the APRS-IS network is disabled) NOT YET FUNCTIONING + port: 14580 # APRS-IS server port + filter: "b/PE1RXF* g/PE1RXF*" # APRS-IS incomming feed filter + +# APRS weather station section +weather: + - port: ax0 # Linux AX.25 port to which APRS weather report is sent + call: PE1RXF-13 # Call from which transmissions are made (can be a different call from the call assigned to the AX.25 port) + destination: APZMDM # APRS destination + digi_path: WIDE2-2 # Digipeater path for weather reports (0 = no path) + position: 5302.76N/00707.85E_ # The position string for the weather station + interval: 10 # Time between weather report transmissions (0 = disable) + - port: ax1 + call: PE1RXF-13 + destination: APZMDM + digi_path: WIDE2-2 + position: 5302.76N/00707.85E_ + interval: 10 + - port: aprsis + call: PE1RXF-13 + destination: APZMDM + digi_path: 0 + position: 5302.76N/00707.85E_ + interval: 10 + +# APRS beacon section +beacon: + - port: ax0 # The AX.25 port on which to transmit (use aprsis for beaconing to the internet via APRS-IS, set to 0 if you want to use the call assigned to the port in /etc/ax25/axports) + call: PE1RXF-1 # Call from which transmissions are made (can be a different call from the call assigned to the AX.25 port) + destination: APRX29 # APRS destination + digi_path: WIDE2-1 # Specifie the digipeater path (best practise is to use WIDE2-1, WIDE2-2 or set to 0 for no path) + position: "!5302.78NR00707.91E&" # The position string for the beacon (better to put this string between parentheses) + message: APRS RX iGATE 144.800MHz # The beacon text + interval: 30 # Beacon interval in minutes + - port: ax1 + call: PE1RXF-3 + destination: APRX29 + digi_path: WIDE2-1 + position: "!5302.78NL00707.91E&" + message: LoRa APRS RX iGATE 433.775MHz + interval: 30 + - port: aprsis + call: PE1RXF-1 + destination: APRX29 + digi_path: 0 + position: "!5302.78NR00707.91E&" + message: APRS RX iGATE 144.800MHz + interval: 10 + - port: aprsis + call: PE1RXF-3 + destination: APRX29 + digi_path: 0 + position: "!5302.78NL00707.91E&" + message: LoRa APRS RX iGATE 433.775MHz + interval: 10 diff --git a/pe1rxf_telemetry.yml b/pe1rxf_telemetry.yml new file mode 100644 index 0000000..df2f28e --- /dev/null +++ b/pe1rxf_telemetry.yml @@ -0,0 +1,5 @@ +# The settings for the PE1RXF telemetry server are configured in this file +# +# NOTE: At the start, the program randomizes the starting time of every individual periodic transmission, +# so even if all intervals are equal, the transmissions are not at the same time, but rather spread over time. +# 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/dist/pythonax25-1.0-py3.11-linux-armv7l.egg b/python-ax25/dist/pythonax25-1.0-py3.11-linux-armv7l.egg new file mode 100644 index 0000000000000000000000000000000000000000..65982b5fc9d5a4bf29a0a3ffabb1ae80f7af8c23 GIT binary patch literal 18480 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 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..6a1e4c1 --- /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/pythonax25.egg-info/PKG-INFO b/python-ax25/pythonax25.egg-info/PKG-INFO new file mode 100644 index 0000000..fa1d0bf --- /dev/null +++ b/python-ax25/pythonax25.egg-info/PKG-INFO @@ -0,0 +1,4 @@ +Metadata-Version: 2.1 +Name: pythonax25 +Version: 1.0 +Summary: CPython extension for LINUX ax.25 stack diff --git a/python-ax25/pythonax25.egg-info/SOURCES.txt b/python-ax25/pythonax25.egg-info/SOURCES.txt new file mode 100644 index 0000000..a0d5bb6 --- /dev/null +++ b/python-ax25/pythonax25.egg-info/SOURCES.txt @@ -0,0 +1,7 @@ +README.md +pythonax25module.c +setup.py +pythonax25.egg-info/PKG-INFO +pythonax25.egg-info/SOURCES.txt +pythonax25.egg-info/dependency_links.txt +pythonax25.egg-info/top_level.txt \ No newline at end of file diff --git a/python-ax25/pythonax25.egg-info/dependency_links.txt b/python-ax25/pythonax25.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/python-ax25/pythonax25.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/python-ax25/pythonax25.egg-info/top_level.txt b/python-ax25/pythonax25.egg-info/top_level.txt new file mode 100644 index 0000000..4cef70d --- /dev/null +++ b/python-ax25/pythonax25.egg-info/top_level.txt @@ -0,0 +1 @@ +pythonax25 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]) diff --git a/start_weater_station.sh b/start_weater_station.sh new file mode 100755 index 0000000..db6766c --- /dev/null +++ b/start_weater_station.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# Start weather_station software +/usr/bin/python3 /home/marcel/ham/weather_station/weather_station_rs485_client.py -c /home/marcel/ham/weather_station/config.yml & + diff --git a/weather_station_modbus.py b/weather_station_modbus.py new file mode 100644 index 0000000..45b1b59 --- /dev/null +++ b/weather_station_modbus.py @@ -0,0 +1,134 @@ +"""" +ModBus control routines +Copyright (C) 2023 M.T. Konstapel + +This program 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. + +This program 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 this program. If not, see . +""" + +import minimalmodbus +import serial +from retrying import retry +#from epevermodbus.extract_bits import extract_bits + + +class WeatherStation(minimalmodbus.Instrument): + """Instrument class for Epever Charge Controllers. + + Args: + * portname (str): port name + * slaveaddress (int): slave address in the range 1 to 247 + + """ + + @retry(wait_fixed=10000, stop_max_attempt_number=5) + def __init__(self, portname, slaveaddress): + minimalmodbus.Instrument.__init__(self, portname, slaveaddress) + self.serial.baudrate = 9600 + self.serial.bytesize = 8 + self.serial.parity = serial.PARITY_NONE + self.serial.stopbits = 1 + self.serial.timeout = 1 + self.mode = minimalmodbus.MODE_RTU + self.clear_buffers_before_each_transaction = True + self.wx_data = [] + + @retry(wait_fixed=200, stop_max_attempt_number=5) + def retriable_read_register( + self, registeraddress, number_of_decimals, functioncode, signed=False + ): + return self.read_register( + registeraddress, number_of_decimals, functioncode, signed + ) + + @retry(wait_fixed=200, stop_max_attempt_number=5) + def retriable_read_bit(self, registeraddress, functioncode): + return self.read_bit(registeraddress, functioncode) + + @retry(wait_fixed=200, stop_max_attempt_number=5) + def retriable_write_bit(self, registeraddress, data, functioncode): + return self.write_bit(registeraddress, data, functioncode) + + #Address range 0x3000 + def get_id(self): + """PV array rated voltage""" + return self.retriable_read_register(0, 0, 4) + + def get_wind_direction(self): + """PV array rated current""" + return self.retriable_read_register(1, 1, 4) + + def get_wind_speedl(self): + """PV array rated power (low 16 bits)""" + return self.retriable_read_register(2, 2, 4) + + def get_wind_gust(self): + """PV array rated power (high 16 bits)""" + return self.retriable_read_register(3, 2, 4) + + def get_temperature(self): + """Rated Battery's voltage""" + return self.retriable_read_register(4, 2, 4, True) + + def get_rain(self): + """Rated charging current to battery""" + return self.retriable_read_register(5, 2, 4) + + def get_rain_last24(self): + """Rated charging power to battery (low 16 bits)""" + return self.retriable_read_register(6, 2, 4) + + def get_rain_since_midnight(self): + """Charging equipment rated output power (high 16 bits)""" + return self.retriable_read_register(7, 0, 4) + + def get_humidity(self): + """Charging mode: 0x0001 = PWM""" + return self.retriable_read_register(8, 2, 4) + + def get_pressure(self): + """Charging mode: 0x0001 = PWM""" + return self.retriable_read_register(9, 1, 4) + + def get_temperature_backup(self): + """Charging mode: 0x0001 = PWM""" + return self.retriable_read_register(13, 2, 4,True) + + def get_status_bits(self): + """Charging mode: 0x0001 = PWM""" + return self.retriable_read_register(14, 0, 4) + + def enable_heater(self): + self.retriable_write_bit(0, 1, 5) + + def disable_heater(self): + self.retriable_write_bit(0, 0, 5) + + def get_weather_data(self): + try: + self.wx_data={} + self.wx_data['ID'] = self.get_id() + self.wx_data['Wind direction'] = self.get_wind_direction() + self.wx_data['Wind speed'] = self.get_wind_speedl() + self.wx_data['Wind gust'] = self.get_wind_gust() + self.wx_data['Rain last hour'] = self.get_rain() + self.wx_data['Rain last 24 hours'] = self.get_rain_last24() + self.wx_data['Temperature'] = self.get_temperature() + self.wx_data['Humidity'] = self.get_humidity() + self.wx_data['Pressure'] = self.get_pressure() + self.wx_data['Temp backup'] = self.get_temperature_backup() + self.wx_data['Status bits'] = self.get_status_bits() + + return 1 + except: + return 0