Compare commits

..

6 Commits

Author SHA1 Message Date
marcel
adecfc923c Test software-file is now main software-file 2025-01-26 21:13:55 +01:00
marcel
7e2aa14f2a Luminosity sensor added, APRSIS messages are now forwarded to MQTT 2025-01-26 20:43:35 +01:00
marcel
589f303a16 Fixed crash when uploading empty APRS message to APRS-IS 2024-03-28 09:23:51 +01:00
marcel
88e486936b port number of MQTT broker can now be changed via YAML file 2024-03-14 21:16:39 +01:00
marcel
6a0b201507 PE1RXF telemetry added 2024-03-14 13:17:38 +01:00
marcel
e55915d2c7 Working iGate 2024-02-20 17:10:14 +01:00
11 changed files with 1181 additions and 114 deletions

View File

@@ -31,10 +31,49 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- connected to APRS-IS feed - connected to APRS-IS feed (seperate thread)
## [0.1.1] - 2024-02-17 ## [0.1.1] - 2024-02-17
### Added ### Added
- iGate functionality - iGate functionality
## [0.1.2] - 2024-02-20
### Fixed
- Convert aprs payload byte stings to character strings used utf-8, which gave errors. It now uses latin-1.
### Changed
- Reading weather station is now a scheduled task.
## [0.1.3] - 2024-03-14
### Added
- PE1RXF telemetry to MQTT bridge
- Forwarding of APRS messages to MQTT
## [0.1.4] - 2024-03-28
### Fixed
- Sporadic crash when uploading RF packet to APRS-IS when payload (message) is empty. Added extra check to payload.
## [0.1.5] - 2025-01-14
### Added
- Luminocity meter is now available in both APRS weather reports as well as in MQTT messages
## [0.1.6] - 2025-01-26
### Fixed
- Luminosity sensor values are now correct
### Added
- Messages from APRSIS are now checked and processed. If message is for local call and sender is not blacklisted, the message is forwarded to MQTT (same as messages received via air already where)

View File

@@ -2,6 +2,10 @@
A basic Linux APRS iGate and APRS weather station with additional (optional) PE1RXF telemetry support. A basic Linux APRS iGate and APRS weather station with additional (optional) PE1RXF telemetry support.
TODO:
- change heater algoritm back to 5 min on/15 min off, but start at 92% in stead of 96%. If the cooling period is less than 15 minutes, the temperture readings are periodically off by 1-1.5 degrees.
## Requirements ## Requirements
- Python3 - Python3
@@ -10,6 +14,7 @@ A basic Linux APRS iGate and APRS weather station with additional (optional) PE1
- gps3 - gps3
- schedule - schedule
- aprslib - aprslib
- paho.mqtt
## License ## License

239
aprs_telemetry_to_mqtt.py Executable file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/python3
''''
# A bridge between PE1RXF APRS telemetry messaging and MQTT.
# It uses pythonax25 (https://github.com/josefmtd/python-ax25)
#
# 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 <https://www.gnu.org/licenses/>.
'''
import sys
import random
import time
from time import gmtime, strftime
#import os
#from pathlib import Path
import yaml
from yaml.loader import SafeLoader
#from paho.mqtt import client as mqtt_client
import paho.mqtt.publish as publish
import logging
import json
#import csv
logger = logging.getLogger("aprs_telemetry_to_mqtt")
class aprs_telemetry_to_mqtt:
# initiate class: define name configuration files
def __init__(self, telemetry_config_file):
self.config_file = telemetry_config_file
logger.info("Initializing telemetry to mqtt bridge.")
def read_settings(self):
if self.read_config_file() == 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 ("Telemetry configuration file ./" + self.config_file + " not found or syntax error in file.")
return 0
else:
return 1
def test_telemetry_settings(self):
try:
tmp = self.config_file_settings['global']['broker']
tmp = self.config_file_settings['global']['port']
tmp = self.config_file_settings['global']['topic_root']
tmp = self.config_file_settings['global']['publish_messages']
tmp = self.config_file_settings['global']['call']
tmp = self.config_file_settings['global']['weather_report_interval']
tmp = self.config_file_settings['global']['blacklist']
tmp = self.config_file_settings['topics']
except:
print ("Error in the telemetry configuration file.")
return 0
else:
#print (self.config_file_settings['global']['topic_root'])
tmp = self.config_file_settings['global']['topic_root']
#mqtt_client_id = f'{self.config_file_settings['global']['topic_root']}-{random.randint(0, 1000)}'
self.mqtt_client_id = f'{tmp}-{random.randint(0, 1000)}'
return 1
# Publish a single message to the MQTT broker
def publish(self, topic, message):
try:
#publish.single(topic, message, hostname=self.config_file_settings['global']['broker'])
publish.single(topic, message, hostname=self.config_file_settings['global']['broker'], port=self.config_file_settings['global']['port'], client_id=self.mqtt_client_id)
except:
logger.debug("Failed to connect to MQTT broker.")
else:
logger.debug('Published: ' + topic + '=' + message)
# Checks if payload (APRS message) contains valid PE1RXF telemetry data.
# If so, it sends the formatted data to the MQTT broker.
# Configuration is done via the file pe1rxf_telemetry.yml
def publish_telemetry_message(self, source, ax_device, ax_address, payload):
values=0
for topics in self.config_file_settings['topics']:
# Check source call
if source == topics['call']:
# Check ax_port
if topics['ax_port'] == 'all' or topics['ax_port'] == ax_device:
#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(" ", "") == ax_address:
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 self.is_float(field):
return 0
# One anoying thing of the PE1RXF telemetry standard is that there is also a message containing the status of the output bits.
# These messages are interpreted as valid telemetry data by this program. The message is send after a '06' command. This program
# does not request this message, but another program might. So we have to filter these messages output
if len(values[0]) == 5:
allowed = '0' + '1'
# Removes from the original string all the characters that are allowed, leaving us with a set containing either a) nothing, or b) the #offending characters from the string:'
if not set(values[0]) - set(allowed):
print ("Probably digital status bits. Ignore.")
return 0
# Check if number of telemtry 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 = self.config_file_settings['global']['topic_root'] + '/' + topics['name'] + '/' + descr
#self.publish(client,current_topic,values[index])
try:
publish.single(current_topic, values[index], hostname=self.config_file_settings['global']['broker'])
except:
print("Failed to connect to MQTT broker.")
else:
print('Published: ' + current_topic + '=' + values[index])
'''
# Loop through descriptions and send values from telemtry file along with it
publish_list = []
for index, descr in enumerate(topics['description'], start=0):
current_topic = self.config_file_settings['global']['topic_root'] + '/' + topics['name'] + '/' + descr
publish_list.append({"topic": current_topic, "payload": values[index]})
try:
#publish.multiple(publish_list, hostname=self.config_file_settings['global']['broker'])
publish.multiple(publish_list, hostname=self.config_file_settings['global']['broker'], port=self.config_file_settings['global']['port'], client_id=self.mqtt_client_id)
except:
logger.debug("Failed to connect to MQTT broker.")
else:
logger.debug('Published telemetry to mqtt broker.')
logger.debug(publish_list)
return values
# Checks if payload (APRS message) contains valid message to us.
# If so, it sends the formatted data to the MQTT broker.
# Configuration is done via the file pe1rxf_telemetry.yml
def publish_aprs_messages(self, source, ax_device, payload):
#logger.debug('Check if message is for us.')
#logger.debug(self.config_file_settings['global']['publish_messages'])
if self.config_file_settings['global']['publish_messages'] == True:
#logger.debug('Configured to forward messages.')
mqtt_message = {}
# Loop through blacklist and check if source is not in it
for call in self.config_file_settings['global']['blacklist']:
if call == source:
print ("Call " + call + " blacklisted. Message not send to mqtt broker.")
return 0
# If we come to here the sender is not in the blacklist
# But is could well be an ordinary aprs packet, so let's check if it is a message.'
# Split payload at colon. If it is a valid message, we should get three
# substrings: the first in empty, the second with the call of the ax25
# interface and the thirth with the message itself
split_message=payload.split(":")
#logger.debug(split_message)
if len(split_message) == 3:
#Remove spaces from destination call
split_message[1] = split_message[1].replace(" ", "")
# check if call defined in the configuration file is part of the destination call. This way we can also catch all the prefixes
if self.config_file_settings['global']['call'] in split_message[1]:
print ('Received from: ' + source + ' payload: ' + split_message[2])
mqtt_message['from'] = source
mqtt_message['to'] = split_message[1]
mqtt_message['port'] = ax_device
mqtt_message['time'] = time.strftime("%Y-%m-%d %H:%M:%S", gmtime())
mqtt_message['message'] = split_message[2]
message = json.dumps(mqtt_message)
topic = self.config_file_settings['global']['topic_root'] + '/aprs_message'
self.publish(topic, message)
# Test if string is a number
def is_float(self, v):
try:
f=float(v)
except:
return False
return True

36
ax25.py
View File

@@ -1,36 +0,0 @@
'''
# 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 <https://www.gnu.org/licenses/>.
'''
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

203
pe1rxf_aprs.py Normal file → Executable file
View File

@@ -1,3 +1,4 @@
#!/usr/bin/python3
''' '''
# A basic APRS iGate and APRS weather station with additional (optional) PE1RXF telemetry support # A basic APRS iGate and APRS weather station with additional (optional) PE1RXF telemetry support
# #
@@ -7,7 +8,7 @@
# #
# This program also has a PE1RXF APRS telemetry to MQTT bridge, which is configurable via pe1rxf_telemetry.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 # Copyright (C) 2023-2025 M.T. Konstapel https://meezenest.nl/mees
# #
# This file is part of weather_station # This file is part of weather_station
# #
@@ -37,13 +38,15 @@ from threading import Thread
from weather_station_modbus import WeatherStation from weather_station_modbus import WeatherStation
from config_reader import config_reader from config_reader import config_reader
from aprs_telemetry_to_mqtt import aprs_telemetry_to_mqtt
main_config_file = "pe1rxf_aprs.yml" main_config_file = "pe1rxf_aprs.yml"
telemetry_config_file = "pe1rxf_telemetry.yml" telemetry_config_file = "pe1rxf_telemetry.yml"
rflog_file = "" rflog_file = ""
# Make Weather data global so scheduled task can use it # Make Weather data global so scheduled task can use it
WxData = [] WxData = {}
APRSIS = [] APRSIS = []
aprsis_data = ['', '',False] # Global variable for storing received APRSIS frames. Shared between threads. [0]=from_call, [1]=payload, [2]=token (set by thread, reset by main loop)
axport = [] axport = []
axdevice = [] axdevice = []
@@ -123,6 +126,8 @@ def parsePacket(string):
# Split the address and payload separated by APRS PID # Split the address and payload separated by APRS PID
buffer = string.split(b'\x03\xf0') buffer = string.split(b'\x03\xf0')
address = buffer[0] address = buffer[0]
#Define empty list in case packet does not has digipeaters in path
digipeaters = []
# Check if the first byte indicates it is a data packet # Check if the first byte indicates it is a data packet
if address[0] == 0: if address[0] == 0:
@@ -201,14 +206,38 @@ def getAllAddress(packetAddress):
def process_aprsis(packet): def process_aprsis(packet):
global aprsis_data
# Setup MQTT connection
mqtt_connection2 = aprs_telemetry_to_mqtt(telemetry_config_file)
mqtt_connection2.read_settings()
#print("Received APRSIS") #print("Received APRSIS")
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", gmtime()) timestamp = time.strftime("%Y-%m-%d %H:%M:%S", gmtime())
if rflog_file == 0: if rflog_file == 0:
return 0 return 0
string = timestamp + ' ' + 'APRSIS ' + ' R ' + str(packet, 'utf-8') + '\n' string = timestamp + ' ' + 'APRSIS ' + ' R ' + str(packet, 'latin-1') + '\n'
# Check if frame is a message to us. If so send it to mqtt
# Split packet at first occurance of ':', which seperates the header from the payload
aprsis_payload = str(packet, 'latin-1').split(':', 1)
# We should have two substrings
if len(aprsis_payload) == 2:
# extract from_call
aprsis_data[0] = aprsis_payload[0].split('>',1)
aprsis_data[1] = aprsis_payload[1]
aprsis_data[2] = True
#print ("In thread:")
#print (aprsis_data[0])
#print (aprsis_data[1])
# write APRSIS string to log file
try: try:
with open(rflog_file, "a") as logfile: with open(rflog_file, "a") as logfile:
logfile.write(string) logfile.write(string)
@@ -218,6 +247,14 @@ def process_aprsis(packet):
return 1 return 1
def send_aprsis(srcCall, dest, digi, msg): def send_aprsis(srcCall, dest, digi, msg):
# Bug, once the program crashed when accessing msg[0] because of "error IndexError: string index out of range". This should never happen, because the function parsePacket checks for this.
# But id did happen, don't know why. But to prevent is from happening again, we check it here as well:
try:
tmp = msg[0]
except:
print("Empty APRS message, nothing to do.")
return 1
try: try:
APRSIS.connect() APRSIS.connect()
except: except:
@@ -236,6 +273,17 @@ def send_aprsis(srcCall, dest, digi, msg):
if ('NOGATE' in digi): if ('NOGATE' in digi):
print(">>> NOGATE, not igated") print(">>> NOGATE, not igated")
return 1 return 1
if msg[0] == '}':
if ('TCPIP' in msg) or ('TCPXX' in msg):
print(">>> Third party packet, not igated")
return 1
# TODO: strip third party header and send to igate
else:
print(">>> Third party packet, not igated")
return 1
if msg[0] == '?':
print(">>> Query, not igated")
return 1
message = srcCall + '>' + dest + ',' + digi + ':' + msg message = srcCall + '>' + dest + ',' + digi + ':' + msg
@@ -293,22 +341,28 @@ def send_aprs_weather_report(weather_settings):
wind_direction = int(WxData['Wind direction']) wind_direction = int(WxData['Wind direction'])
wind_speed = int(2.2369 * WxData['Wind speed']) wind_speed = int(2.2369 * WxData['Wind speed'])
wind_gust = int(2.2369 * WxData['Wind gust']) wind_gust = int(2.2369 * WxData['Wind gust'])
#rain_lasthour = int(3.93700787 * WxData['Rain last hour']) rain_lasthour = int(3.93700787 * WxData['Rain last hour'])
rain_lasthour = 0 #rain_lasthour = 0
#rain_24hour = int(3.93700787 * WxData['Rain last 24 hours']) rain_24hour = int(3.93700787 * WxData['Rain last 24 hours'])
rain_24hour = 0 #rain_24hour = 0
temperature = int(WxData['Temperature'] * 1.8 + 32) temperature = int(WxData['Temperature'] * 1.8 + 32)
humidity = int(WxData['Humidity']) humidity = int(WxData['Humidity'])
if (humidity == 100): if (humidity == 100):
humidity = 0; humidity = 0;
pressure =int(10 * WxData['Pressure']) pressure =int(10 * WxData['Pressure'])
luminosity = int(0.0079 * WxData['Luminosity']) # W/m2: various sources give 0.0079 or 0.0083 as an approxmation for sunlight
if (luminosity <= 999):
APRS_Lum = 'L'
else:
APRS_Lum = 'l'
luminosity = luminosity-1000
# Get date and time # Get date and time
timestamp = time.strftime("%d%H%M", gmtime()) timestamp = time.strftime("%d%H%M", gmtime())
# Construct APRS weather report # Construct APRS weather report
aprs_position = weather_settings['position'] 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) 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) + APRS_Lum + "{:03d}".format(luminosity)
if weather_settings['port'] == 'aprsis': if weather_settings['port'] == 'aprsis':
send_aprsis(weather_settings['call'], weather_settings['destination'], weather_settings['digi_path'], aprs_wx_report) send_aprsis(weather_settings['call'], weather_settings['destination'], weather_settings['digi_path'], aprs_wx_report)
@@ -352,16 +406,52 @@ def send_telemetry():
send_ax25('PE1RXF-3', 'PE1RXF-13', "APZMDM", 0, message) send_ax25('PE1RXF-3', 'PE1RXF-13', "APZMDM", 0, message)
def publish_weather_data(mqtt_client):
payload = ':' + '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']) + ',' + str(WxData['Luminosity'])
telemetry=mqtt_client.publish_telemetry_message("PE1RXF-13", "ax1", "PE1RXF-3", payload)
return 1
def read_weather_station(weather_station):
global WxData
#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
print (WxData)
def check_heater(weather_station): def check_heater(weather_station):
# Check if heater is off, if so, turn it on # Check if heater is off, if so, turn it on
if weather_station.wx_data['Status bits'] & 0x4 == 0: if weather_station.wx_data['Status bits'] & 0x4 == 0:
weather_station.enable_heater() weather_station.enable_heater()
print("Heater was off, turned it back on gain.") print("Heater was off, turned it back on gain.")
def check_thread_alive(thr):
thr.join(timeout=0.0)
# returns True if the thread is still running and False, otherwise
return thr.is_alive()
def run(): def run():
global WxData global WxData
# Fill WxData with some sensible data to start:
WxData['ID'] = 0.0
WxData['Wind direction'] = 0.0
WxData['Wind speed'] = 0.0
WxData['Wind gust'] = 0.0
WxData['Rain last hour'] = 0.0
WxData['Rain last 24 hours'] = 0.0
WxData['Temperature'] = 0.0
WxData['Humidity'] = 0.0
WxData['Pressure'] = 0.0
WxData['Luminosity'] = 0.0
WxData['Temp backup'] = 0.0
WxData['Status bits'] = 0.0
# Debug logging for aprslib # Debug logging for aprslib
logging.basicConfig(level=logging.DEBUG) # level=10 logging.basicConfig(level=logging.DEBUG) # level=10
@@ -379,6 +469,10 @@ def run():
print ("Write APRS frames to: " + rflog_file) print ("Write APRS frames to: " + rflog_file)
print ("Read configuration files.") print ("Read configuration files.")
# Setup MQTT connection
mqtt_connection = aprs_telemetry_to_mqtt(telemetry_config_file)
mqtt_connection.read_settings()
rx_socket = setup_ax25() rx_socket = setup_ax25()
# a valid passcode for the callsign is required in order to send # a valid passcode for the callsign is required in order to send
@@ -396,14 +490,14 @@ def run():
else: else:
print("Got response from the weather station. Weather information is available.") 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. # NOTE: Is now done periodically. So when the weather station is unplugged and plugged back in, the heater will be enabled again.
try: #try:
weather_station.enable_heater() # weather_station.enable_heater()
except: #except:
print("No response from the weather station via the ModBus, even after several retries. Disabling the weather station.") # 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! # sys.exit(1) # Make program work without weather station!
else: #else:
print("Enabled the heater function on the weather station.") # print("Enabled the heater function on the weather station.")
### ###
# Schedule all periodic transmissions (use a little randomness) # Schedule all periodic transmissions (use a little randomness)
@@ -424,20 +518,30 @@ def run():
interval = interval * 60 # from minutes to seconds interval = interval * 60 # from minutes to seconds
schedule.every(interval - 59).to(interval + 59).seconds.do(send_aprs_beacon, entry) schedule.every(interval - 59).to(interval + 59).seconds.do(send_aprs_beacon, entry)
# Schedule telemetry transmision # Schedule check if heater is still on
print("Scheduled telemetry transmission.") # So when the weather station is unplugged and plugged back in, the heater will be enabled again.
schedule.every(10).minutes.do(send_telemetry)
# ScheduleL check if heater is still on
schedule.every(10).minutes.do(check_heater, weather_station) schedule.every(10).minutes.do(check_heater, weather_station)
# Schedule readout of weather station
print("Scheduled readout of weather station.")
schedule.every(1).minutes.do(read_weather_station, weather_station)
# Schedule telemetry transmision
#print("Scheduled telemetry transmission.")
#schedule.every(10).minutes.do(send_telemetry)
print("Schedule mqtt weather publisher.")
interval = mqtt_connection.config_file_settings['global']['weather_report_interval']
if interval != 0:
schedule.every(interval).minutes.do(publish_weather_data, mqtt_connection)
# Connect to incoming APRS-IS feed # Connect to incoming APRS-IS feed
# by default `raw` is False, then each line is ran through aprslib.parse() # by default `raw` is False, then each line is ran through aprslib.parse()
# Set filter on incomming feed # Set filter on incomming feed
APRSIS.set_filter(Configuration.config_file_settings['aprsis']['filter']) APRSIS.set_filter(Configuration.config_file_settings['aprsis']['filter'])
# This is a blocking call, should run as seperate thread # This is a blocking call, should run as seperate thread. Data from aprsis is stored in aprsid_data. [0]=from_call, [1]=payload, [2]=token (set by thread, reset by main loop)
# create a thread # create a thread
global aprsis_data
thread = Thread(target=APRSIS.consumer, args=(process_aprsis, True, True, True)) thread = Thread(target=APRSIS.consumer, args=(process_aprsis, True, True, True))
# run the thread # run the thread
thread.start() thread.start()
@@ -445,22 +549,34 @@ def run():
while (1): while (1):
#print ("Reading registers of weather station.") #print ("Reading registers of weather station.")
if weather_station.get_weather_data() == 0: #if weather_station.get_weather_data() == 0:
print ('No response from ModBus, even after 5 retries. Keep trying.') # print ('No response from ModBus, even after 5 retries. Keep trying.')
else: #else:
WxData = weather_station.wx_data # WxData = weather_station.wx_data
# Scheduler
schedule.run_pending() schedule.run_pending()
# Check if APRS-IS thread is still running, if not restart it
if check_thread_alive(thread) == False:
# 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()
# Listen on all ax25 ports and send to APRS-IS
receive = receive_ax25(rx_socket) receive = receive_ax25(rx_socket)
for port in range(len(axdevice)): for port in range(len(axdevice)):
if receive[0][1] == axdevice[port]: if receive[0][1] == axdevice[port]:
#print(receive) #print(receive)
source, destination, digipeaters, payload = parsePacket(receive[1]) source, destination, digipeaters, payload = parsePacket(receive[1])
#print(receive[1]) #print(receive[1])
# bug UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf8 in position 47: invalid start byte # Convert byte string to normal string
try: try:
payload = payload.decode() payload = payload.decode('latin-1')
except: except:
payload = 'NOT VALID' payload = 'NOT VALID'
@@ -474,16 +590,35 @@ def run():
#aprs_frame = axaddress[0] + '>' + destination + ',' + ','.join(digipeaters) + ':' + payload #aprs_frame = axaddress[0] + '>' + destination + ',' + ','.join(digipeaters) + ':' + payload
#print (aprs_frame) #print (aprs_frame)
log_ax25(axaddress[port], source, destination, ','.join(digipeaters), payload, 'R') if payload == 'NOT VALID' or payload == 0:
digipeaters.append('qAR') print (">>> Packet not valid, ignored.")
digipeaters.append(Configuration.config_file_settings['aprsis']['call']) else:
send_aprsis(source, destination, ','.join(digipeaters), payload) log_ax25(axaddress[port], source, destination, ','.join(digipeaters), payload, 'R')
#telemetry=process_message(source, port, payload, client) digipeaters.append('qAR')
digipeaters.append(Configuration.config_file_settings['aprsis']['call'])
send_aprsis(source, destination, ','.join(digipeaters), payload)
# Check if APRS frame is PE1RXF telemetry
telemetry=mqtt_connection.publish_telemetry_message(source, axdevice[port], axaddress[port], payload)
# Check if APRS frame is a message to us
mqtt_connection.publish_aprs_messages(source, axdevice[port], payload)
# If APRSIS read thread receives message for us, send it to MQTT
if aprsis_data[2] == True:
aprsis_data[2] = False
print ("In loop:")
print (aprsis_data[0])
print (aprsis_data[1])
mqtt_connection.publish_aprs_messages(aprsis_data[0][0], 'APRSIS', aprsis_data[1])
#time.sleep(1) # Short sleep #time.sleep(1) # Short sleep
if __name__ == '__main__': if __name__ == '__main__':
sys.stdout = sys.stderr = open('/home/marcel/pe1rxf_aprs_debug.log', 'w') sys.stdout = sys.stderr = open('/home/marcel/pe1rxf_aprs_debug.log', 'w')

View File

@@ -7,25 +7,25 @@
# #
# This section (ax25) is being depricated # This section (ax25) is being depricated
ax25: #ax25:
call: PE1RXF-13 # Call from which transmissions are made # call: PE1RXF-13 # Call from which transmissions are made
destination: APZMDM # APRS destination # destination: APZMDM # APRS destination
telemetry_port: ax1 # Linux AX.25 port to which telemetry is sent # 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_digi_path: 0 # Digipeater path for telemetry messages (0 = no path)
telemetry_interval: 5 # Time between telemetry transmissions # telemetry_interval: 5 # Time between telemetry transmissions
telemetry_server: PE1RXF-3 # PE1RXF telemetry server call # telemetry_server: PE1RXF-3 # PE1RXF telemetry server call
weather_report_port: ax0 # Linux AX.25 port to which telemetry is sent # 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_digi_path: WIDE2-2 # Digipeater path for weather reports (0 = no path)
weather_report_interval: 10 # Time between weather report transmissions # weather_report_interval: 10 # Time between weather report transmissions
# Global settings # Global settings
global: global:
log-rf: /home/marcel/test/pe1rxf_aprs-rf.log # Log RF traffic to file (0=no logging) log-rf: /home/marcel/ham/weather_station/pe1rxf_aprs-rf.log # Log RF traffic to file (0=no logging)
modbus: 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 port: /dev/serial/by-path/platform-3f980000.usb-usb-0:1.3:1.0-port0 # USB port to which RS-485 dongle is connected
address: 14 # ModBus address of weather station address: 14 # ModBus address of weather station
# APRS-IS section # APRS-IS section
@@ -41,15 +41,15 @@ weather:
- port: ax0 # Linux AX.25 port to which APRS weather report is sent - 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) 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 destination: APZMDM # APRS destination
digi_path: WIDE2-2 # Digipeater path for weather reports (0 = no path) digi_path: WIDE2-1 # Digipeater path for weather reports (0 = no path)
position: 5302.76N/00707.85E_ # The position string for the weather station position: 5302.76N/00707.85E_ # The position string for the weather station
interval: 10 # Time between weather report transmissions (0 = disable) interval: 30 # Time between weather report transmissions (0 = disable)
- port: ax1 - port: ax1
call: PE1RXF-13 call: PE1RXF-13
destination: APZMDM destination: APZMDM
digi_path: WIDE2-2 digi_path: WIDE2-1
position: 5302.76N/00707.85E_ position: 5302.76N/00707.85E_
interval: 10 interval: 30
- port: aprsis - port: aprsis
call: PE1RXF-13 call: PE1RXF-13
destination: APZMDM destination: APZMDM
@@ -59,31 +59,38 @@ weather:
# APRS beacon section # APRS beacon section
beacon: 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) - port: ax1 # 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) 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 destination: APZMDM # 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) 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) 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 message: APRS RX iGATE 144.800MHz # The beacon text
interval: 30 # Beacon interval in minutes interval: 30 # Beacon interval in minutes
- port: ax1 - port: ax0
call: PE1RXF-3 call: PE1RXF-3
destination: APRX29 destination: APZMDM
digi_path: WIDE2-1 digi_path: WIDE2-1
position: "!5302.78NL00707.91E&" position: "!5302.78NL00707.91E&"
message: LoRa APRS RX iGATE 433.775MHz message: LoRa APRS RX iGATE 433.775MHz
interval: 30 interval: 30
- port: aprsis - port: aprsis
call: PE1RXF-1 call: PE1RXF-1
destination: APRX29 destination: APZMDM
digi_path: 0 digi_path: 0
position: "!5302.78NR00707.91E&" position: "!5302.78NR00707.91E&"
message: APRS RX iGATE 144.800MHz message: APRS RX iGATE 144.800MHz
interval: 10 interval: 10
- port: aprsis - port: aprsis
call: PE1RXF-3 call: PE1RXF-3
destination: APRX29 destination: APZMDM
digi_path: 0 digi_path: 0
position: "!5302.78NL00707.91E&" position: "!5302.78NL00707.91E&"
message: LoRa APRS RX iGATE 433.775MHz message: LoRa APRS RX iGATE 433.775MHz
interval: 10 interval: 10
- port: aprsis
call: PE1RXF
destination: APJ8CL
digi_path: 0
position: "=5302.78N/00707.90EG"
message: JS8 to APRS RX iGate 7.078MHz
interval: 10

9
pe1rxf_aprs_logrotate.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Rotate the RF logs of pe1rxf_aprs program. If called every week, three weeks worth of logs are kept. Older logs are overwritten
cd /home/marcel/ham/weather_station
cp pe1rxf_aprs-rf.log.2 pe1rxf_aprs-rf.log.3
cp pe1rxf_aprs-rf.log.1 pe1rxf_aprs-rf.log.2
cp pe1rxf_aprs-rf.log pe1rxf_aprs-rf.log.1
/usr/bin/truncate -s 0 pe1rxf_aprs-rf.log

625
pe1rxf_aprs_test.py Executable file
View File

@@ -0,0 +1,625 @@
#!/usr/bin/python3
'''
# 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-2025 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 <https://www.gnu.org/licenses/>.
'''
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
from aprs_telemetry_to_mqtt import aprs_telemetry_to_mqtt
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 = []
aprsis_data = ['', '',False] # Global variable for storing received APRSIS frames. Shared between threads. [0]=from_call, [1]=payload, [2]=token (set by thread, reset by main loop)
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]
#Define empty list in case packet does not has digipeaters in path
digipeaters = []
# 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):
global aprsis_data
# Setup MQTT connection
mqtt_connection2 = aprs_telemetry_to_mqtt(telemetry_config_file)
mqtt_connection2.read_settings()
#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, 'latin-1') + '\n'
# Check if frame is a message to us. If so send it to mqtt
# Split packet at first occurance of ':', which seperates the header from the payload
aprsis_payload = str(packet, 'latin-1').split(':', 1)
# We should have two substrings
if len(aprsis_payload) == 2:
# extract from_call
aprsis_data[0] = aprsis_payload[0].split('>',1)
aprsis_data[1] = aprsis_payload[1]
aprsis_data[2] = True
#print ("In thread:")
#print (aprsis_data[0])
#print (aprsis_data[1])
# write APRSIS string to log file
try:
with open(rflog_file, "a") as logfile:
logfile.write(string)
except:
return 0
else:
return 1
def send_aprsis(srcCall, dest, digi, msg):
# Bug, once the program crashed when accessing msg[0] because of "error IndexError: string index out of range". This should never happen, because the function parsePacket checks for this.
# But id did happen, don't know why. But to prevent is from happening again, we check it here as well:
try:
tmp = msg[0]
except:
print("Empty APRS message, nothing to do.")
return 1
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
if msg[0] == '}':
if ('TCPIP' in msg) or ('TCPXX' in msg):
print(">>> Third party packet, not igated")
return 1
# TODO: strip third party header and send to igate
else:
print(">>> Third party packet, not igated")
return 1
if msg[0] == '?':
print(">>> Query, 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'])
luminosity = int(0.0079 * WxData['Luminosity']) # W/m2: various sources give 0.0079 or 0.0083 as an approxmation for sunlight
if (luminosity <= 999):
APRS_Lum = 'L'
else:
APRS_Lum = 'l'
luminosity = luminosity-1000
# 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) + APRS_Lum + "{:03d}".format(luminosity)
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 publish_weather_data(mqtt_client):
payload = ':' + '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']) + ',' + str(WxData['Luminosity'])
telemetry=mqtt_client.publish_telemetry_message("PE1RXF-13", "ax1", "PE1RXF-3", payload)
return 1
def read_weather_station(weather_station):
global WxData
#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
print (WxData)
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 check_thread_alive(thr):
thr.join(timeout=0.0)
# returns True if the thread is still running and False, otherwise
return thr.is_alive()
def run():
global WxData
# Fill WxData with some sensible data to start:
WxData['ID'] = 0.0
WxData['Wind direction'] = 0.0
WxData['Wind speed'] = 0.0
WxData['Wind gust'] = 0.0
WxData['Rain last hour'] = 0.0
WxData['Rain last 24 hours'] = 0.0
WxData['Temperature'] = 0.0
WxData['Humidity'] = 0.0
WxData['Pressure'] = 0.0
WxData['Luminosity'] = 0.0
WxData['Temp backup'] = 0.0
WxData['Status bits'] = 0.0
# 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.")
# Setup MQTT connection
mqtt_connection = aprs_telemetry_to_mqtt(telemetry_config_file)
mqtt_connection.read_settings()
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: Is now 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 check if heater is still on
# So when the weather station is unplugged and plugged back in, the heater will be enabled again.
schedule.every(10).minutes.do(check_heater, weather_station)
# Schedule readout of weather station
print("Scheduled readout of weather station.")
schedule.every(1).minutes.do(read_weather_station, weather_station)
# Schedule telemetry transmision
#print("Scheduled telemetry transmission.")
#schedule.every(10).minutes.do(send_telemetry)
print("Schedule mqtt weather publisher.")
interval = mqtt_connection.config_file_settings['global']['weather_report_interval']
if interval != 0:
schedule.every(interval).minutes.do(publish_weather_data, mqtt_connection)
# 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. Data from aprsis is stored in aprsid_data. [0]=from_call, [1]=payload, [2]=token (set by thread, reset by main loop)
# create a thread
global aprsis_data
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
# Scheduler
schedule.run_pending()
# Check if APRS-IS thread is still running, if not restart it
if check_thread_alive(thread) == False:
# 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()
# Listen on all ax25 ports and send to APRS-IS
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])
# Convert byte string to normal string
try:
payload = payload.decode('latin-1')
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)
if payload == 'NOT VALID' or payload == 0:
print (">>> Packet not valid, ignored.")
else:
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)
# Check if APRS frame is PE1RXF telemetry
telemetry=mqtt_connection.publish_telemetry_message(source, axdevice[port], axaddress[port], payload)
# Check if APRS frame is a message to us
mqtt_connection.publish_aprs_messages(source, axdevice[port], payload)
# If APRSIS read thread receives message for us, send it to MQTT
if aprsis_data[2] == True:
aprsis_data[2] = False
print ("In loop:")
print (aprsis_data[0])
print (aprsis_data[1])
mqtt_connection.publish_aprs_messages(aprsis_data[0][0], 'APRSIS', aprsis_data[1])
#time.sleep(1) # Short sleep
if __name__ == '__main__':
#sys.stdout = sys.stderr = open('/home/marcel/pe1rxf_aprs_debug.log', 'w')
run()

View File

@@ -1,5 +1,62 @@
# The settings for the PE1RXF telemetry server are configured in this file # The settings for the PE1RXF APRS telemetry to MQTT bridge.
# # The program does some syntax checking, but not extensively. Be aware that the program may crash when there is an error 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.
# #
# Add APRS nodes under topics. More than one can be defined.
# Global settings apply to all other entries
global:
broker: mqtt.meezenest.nl # 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
weather_report_interval: 1 # Publish weather report from weather station on ModBus to MQTT broker every x minutes (0=do not publish)
publish_messages: YES # The program can forward APRS messages addressed to us to the MQTT broker. If YES: publish APRS messages addressed to call to mqtt broker (/topic_root/aprs_message/).
# If NO: do not publish to mqtt broker
call: PE1RXF # Call used for APRS message publishing to mqtt (if no sufix is given, messages for all sufixes will be forwarded to the mqtt broker)
blacklist: # APRS messages from these calls are not published on the mqtt broker (for examle, place the calls from telemetry nodes here. These messages are processed via the 'topic' entry.
- PE1RXF-13 # This way the messages are not also published as plain messages to the mqtt broker.
- PE1RXF-3
- PE1RXF-5
- PE1RXF-6
- PE1RXF-8
- PE1RXF-9
topics:
# MQTT topic: each telemtry node has its own name (sub root) and must be unique
- name: solar_generator
# telemetry_file is obsolete. Use call instead.
#telemetry_file: /home/marcel/ham/aprs_utils/aprs_log/latest_telemetry_PE1RXF-9.dat
# Call of the telemetry node
call: PE1RXF-9
# AX.25 port to listen on (all for all ports)
ax_port: all
# Defines the names of the values in the telemetry data. These names are used to publish to the MQTT broker.
# Make sure the number of descriptions match the number of values in the telemetry data!
description:
- soc
- voltage
- power
- temperature
- name: wx_workshop
call: PE1RXF-6
ax_port: all
description:
- temperature
- humidity
# Definition of the build in weather station telemetry. Set interval in global/weather_report_interval
- name: weather_station
call: PE1RXF-13
ax_port: all
description:
- wind_direction
- wind_speed
- wind_gust
- rain_lasthour
- rain_24hours
- temperature
- humidity
- pressure
- temperature_backup
- status_bits
- luminosity

View File

@@ -1,5 +0,0 @@
#!/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 &

View File

@@ -61,51 +61,42 @@ class WeatherStation(minimalmodbus.Instrument):
#Address range 0x3000 #Address range 0x3000
def get_id(self): def get_id(self):
"""PV array rated voltage"""
return self.retriable_read_register(0, 0, 4) return self.retriable_read_register(0, 0, 4)
def get_wind_direction(self): def get_wind_direction(self):
"""PV array rated current"""
return self.retriable_read_register(1, 1, 4) return self.retriable_read_register(1, 1, 4)
def get_wind_speedl(self): def get_wind_speedl(self):
"""PV array rated power (low 16 bits)"""
return self.retriable_read_register(2, 2, 4) return self.retriable_read_register(2, 2, 4)
def get_wind_gust(self): def get_wind_gust(self):
"""PV array rated power (high 16 bits)"""
return self.retriable_read_register(3, 2, 4) return self.retriable_read_register(3, 2, 4)
def get_temperature(self): def get_temperature(self):
"""Rated Battery's voltage"""
return self.retriable_read_register(4, 2, 4, True) return self.retriable_read_register(4, 2, 4, True)
def get_rain(self): def get_rain(self):
"""Rated charging current to battery"""
return self.retriable_read_register(5, 2, 4) return self.retriable_read_register(5, 2, 4)
def get_rain_last24(self): def get_rain_last24(self):
"""Rated charging power to battery (low 16 bits)"""
return self.retriable_read_register(6, 2, 4) return self.retriable_read_register(6, 2, 4)
def get_rain_since_midnight(self): def get_rain_since_midnight(self):
"""Charging equipment rated output power (high 16 bits)"""
return self.retriable_read_register(7, 0, 4) return self.retriable_read_register(7, 0, 4)
def get_humidity(self): def get_humidity(self):
"""Charging mode: 0x0001 = PWM"""
return self.retriable_read_register(8, 2, 4) return self.retriable_read_register(8, 2, 4)
def get_pressure(self): def get_pressure(self):
"""Charging mode: 0x0001 = PWM"""
return self.retriable_read_register(9, 1, 4) return self.retriable_read_register(9, 1, 4)
def get_luminosity(self):
return self.retriable_read_register(10, 0, 4)
def get_temperature_backup(self): def get_temperature_backup(self):
"""Charging mode: 0x0001 = PWM"""
return self.retriable_read_register(13, 2, 4,True) return self.retriable_read_register(13, 2, 4,True)
def get_status_bits(self): def get_status_bits(self):
"""Charging mode: 0x0001 = PWM"""
return self.retriable_read_register(14, 0, 4) return self.retriable_read_register(14, 0, 4)
def enable_heater(self): def enable_heater(self):
@@ -126,6 +117,7 @@ class WeatherStation(minimalmodbus.Instrument):
self.wx_data['Temperature'] = self.get_temperature() self.wx_data['Temperature'] = self.get_temperature()
self.wx_data['Humidity'] = self.get_humidity() self.wx_data['Humidity'] = self.get_humidity()
self.wx_data['Pressure'] = self.get_pressure() self.wx_data['Pressure'] = self.get_pressure()
self.wx_data['Luminosity'] = self.get_luminosity()
self.wx_data['Temp backup'] = self.get_temperature_backup() self.wx_data['Temp backup'] = self.get_temperature_backup()
self.wx_data['Status bits'] = self.get_status_bits() self.wx_data['Status bits'] = self.get_status_bits()