diff --git a/software/mqtt_to_aprs_weather_report/README.md b/software/mqtt_to_aprs_weather_report/README.md
new file mode 100644
index 0000000..8404f41
--- /dev/null
+++ b/software/mqtt_to_aprs_weather_report/README.md
@@ -0,0 +1,27 @@
+# Python MQTT to APRS weather report bridge
+
+Forwards MQTT messages to AX.25 according to the APRS weather report protocol
+
+## Configuration
+
+Edit config.yaml.
+
+## Requirements
+
+- Python3
+- minimalmodbus
+- json
+- time
+- sys
+- logging
+
+AX.25 stack on Linux. "/usr/sbin/beacon" installed.
+
+## License
+
+Copyright (C) 2025 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/software/mqtt_to_aprs_weather_report/config.yaml b/software/mqtt_to_aprs_weather_report/config.yaml
new file mode 100644
index 0000000..cf6576e
--- /dev/null
+++ b/software/mqtt_to_aprs_weather_report/config.yaml
@@ -0,0 +1,44 @@
+# This is the configuration file for the Mees Electronics weather station MK2
+
+# Global settings
+global:
+ program-log: 0 # All program output will be written to this file (0 = do not log to file)
+ #program-log: /home/marcel/rs458.log # All program output will be written to this file (0 = do not log to file)
+ mqtt-server: mqtt.meezenest.nl
+ mqtt-port: 1883
+ telemetry-interval: 1100 # number of seconds between transmissions
+
+# APRS settings
+aprs:
+ - port: ax1 # Linux AX.25 port to which APRS weather report is sent
+ from_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
+ digipath: 0 # Digipeater path for weather reports (0 = no path)
+ position: 5302.76N/00707.85E_
+# - port: ax1 # 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
+# digipath: WIDE2-1 # Digipeater path for weather reports (0 = no path)
+
+# Define the MQTT subjects for the weather report. The order is important, as this is the order in which the data is interpreted and put in the weather report.
+# - wind_direction
+# - wind_speed
+# - wind_gust
+# - rain_lasthour
+# - rain_24hour
+# - temperature
+# - humidity
+# - pressure
+# - luminosity
+
+mqtt:
+ subscribe:
+ - mees_electronics/4d45000000000002/wind_direction
+ - mees_electronics/4d45000000000002/wind_speed
+ - mees_electronics/4d45000000000002/wind_gust
+ - mees_electronics/4d45000000000002/rain_last_hour
+ - mees_electronics/4d45000000000002/rain_last_24 hours
+ - mees_electronics/4d45000000000002/temperature
+ - mees_electronics/4d45000000000002/humidity
+ - mees_electronics/4d45000000000002/barometric_pressure
+ - mees_electronics/4d45000000000002/luminosity
diff --git a/software/mqtt_to_aprs_weather_report/config_reader.py b/software/mqtt_to_aprs_weather_report/config_reader.py
new file mode 100644
index 0000000..c5bd684
--- /dev/null
+++ b/software/mqtt_to_aprs_weather_report/config_reader.py
@@ -0,0 +1,90 @@
+""""
+ModBus configuration file reader routines
+Copyright (C) 2023-2025 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 yaml
+from yaml.loader import SafeLoader
+
+class config_reader:
+
+ # initiate class: define name configuration files
+ def __init__(self, config_file):
+ self.config_file = config_file
+
+ def read_settings(self):
+
+ if self.read_config_file() == 0:
+ return 0
+
+ if self.test_global_settings() == 0:
+ return 0
+
+ if self.test_aprs_settings() == 0:
+ return 0
+
+ if self.test_mqtt_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
+
+ # Test if all settings are pressent
+ def test_global_settings(self):
+ # Test is all expected settings are present
+ try:
+ tmp = self.config_file_settings['global']['program-log']
+ tmp = self.config_file_settings['global']['mqtt-server']
+ tmp = self.config_file_settings['global']['mqtt-port']
+ tmp = self.config_file_settings['global']['telemetry-interval']
+ except:
+ print ("Error in the global section of the configuration file.")
+ return 0
+ else:
+ return 1
+
+ def test_aprs_settings(self):
+ for entry in self.config_file_settings['aprs']:
+ try:
+ tmp = entry['port']
+ tmp = entry['from_call']
+ tmp = entry['destination']
+ tmp = entry['digipath']
+ tmp = entry['position']
+ except:
+ print ("Error in the aprs section of the configuration file.")
+ return 0
+ else:
+ return 1
+
+ def test_mqtt_settings(self):
+ # Test is all expected settings are present
+ try:
+ tmp = self.config_file_settings['mqtt']['subscribe']
+ except:
+ print ("Error in the mqtt section of the configuration file.")
+ return 0
+ else:
+ return 1
diff --git a/software/mqtt_to_aprs_weather_report/mqtt_callbacks.py b/software/mqtt_to_aprs_weather_report/mqtt_callbacks.py
new file mode 100644
index 0000000..b45ba8f
--- /dev/null
+++ b/software/mqtt_to_aprs_weather_report/mqtt_callbacks.py
@@ -0,0 +1,10 @@
+import logging
+
+
+
+def on_message(client, userdata, message, properties=None):
+ logging.info(
+ f"Received message {message.payload} on topic '{message.topic}' with QoS {message.qos}"
+ )
+def on_subscribe(client, userdata, mid, qos, properties=None):
+ logging.info(f"Subscribed with QoS {qos}")
diff --git a/software/mqtt_to_aprs_weather_report/mqtt_to_aprs_weather_report.py b/software/mqtt_to_aprs_weather_report/mqtt_to_aprs_weather_report.py
new file mode 100644
index 0000000..20fdd8e
--- /dev/null
+++ b/software/mqtt_to_aprs_weather_report/mqtt_to_aprs_weather_report.py
@@ -0,0 +1,233 @@
+""""
+MQTT to aprs weather report for Mees Electronics sensors.
+Subscribes to MQTT broker entries, combines the values and sends
+the data as an aprs weather report over an AX.25 port.
+
+Copyright (C) 2025 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 json
+import time
+from time import gmtime, strftime
+import sys
+import logging
+import datetime
+import subprocess
+import os
+import random
+
+from pathlib import Path
+from config_reader import config_reader
+
+import paho.mqtt.client as mqtt
+from paho.mqtt.client import CallbackAPIVersion
+from paho.mqtt.properties import Properties
+from paho.mqtt.packettypes import PacketTypes
+properties=Properties(PacketTypes.PUBLISH)
+properties.MessageExpiryInterval=30 # in seconds
+#import ssl
+
+class MqttHandler:
+ def __init__(self, config):
+ self.config = config
+ # Define list with length equal to the number of subscriptions defined in the config file
+ self.number_of_mqtt_subscriptions = len(config['mqtt']['subscribe'])
+ self.aprs_telemetry_data = [bytes(b'0')]*self.number_of_mqtt_subscriptions
+
+ def process_message(self, client, userdata, message):
+
+ # Loop through all mqtt:subscribe: entries in config.yaml and see which mqtt message we have. Put in in the correct position of the APRS telemetry string
+ for index, entry in enumerate(self.config['mqtt']['subscribe']):
+ if entry == message.topic:
+ self.aprs_telemetry_data[index] = message.payload
+
+ logging.info(f"Received message {message.payload} on topic '{message.topic}' with QoS {message.qos}")
+
+ def on_connect(self, client, userdata, flags, reason_code, properties=None):
+ logging.info('Connected to MQTT broker.')
+
+ # Returns the weather data
+ def get_aprs_telemetry(self):
+ return self.aprs_telemetry_data
+
+
+def start_mqtt(config):
+
+ version = '3' # or '5'
+ mqtt_transport = 'tcp' # or 'websockets'
+ client_id = f'mees_electronics-mqtt-{random.randint(0, 1000)}'
+
+ if version == '5':
+ client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=client_id,
+ transport=mqtt_transport,
+ protocol=mqtt.MQTTv5)
+ if version == '3':
+ client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=client_id,
+ transport=mqtt_transport,
+ protocol=mqtt.MQTTv311,
+ clean_session=True)
+ #client.username_pw_set("user", "password")
+
+ mqtt_handler = MqttHandler(config)
+ client.on_connect = mqtt_handler.on_connect;
+ client.on_message = mqtt_handler.process_message
+ #client.on_message = mqtt_callbacks.on_message;
+ #client.on_publish = mqtt_callbacks.on_publish;
+ #client.on_subscribe = mqtt_callbacks.on_subscribe;
+
+ if version == '5':
+ from paho.mqtt.properties import Properties
+ from paho.mqtt.packettypes import PacketTypes
+ properties=Properties(PacketTypes.CONNECT)
+ properties.SessionExpiryInterval=30*60 # in seconds
+ client.connect(config['global']['mqtt-server'],
+ port=config['global']['mqtt-port'],
+ clean_start=mqtt.MQTT_CLEAN_START_FIRST_ONLY,
+ properties=properties,
+ keepalive=60);
+
+ elif version == '3':
+ client.connect(config['global']['mqtt-server'], port=config['global']['mqtt-port'], keepalive=60);
+
+ client.loop_start();
+
+ return client, mqtt_handler
+
+
+def setup():
+ config_file = "config.yaml"
+
+ # Read the configuration file
+ configuration = config_reader(config_file)
+
+ if configuration.read_settings() == 0:
+ sys.exit()
+
+ print("Succesfully read the configuration from file: " + config_file)
+
+ # show values from config file
+ if configuration.config_file_settings['global']['program-log'] == 0:
+ print("Program output will not be logged to a file.")
+ else:
+ print("Program output will be logged to file: " + configuration.config_file_settings['global']['program-log'])
+
+ # Enable the logging module. Log to file if enabled in configuration file, otherwise log to stand i/o (probably the screen)
+ logging.basicConfig(
+ level=logging.DEBUG,
+ format="{asctime} - {levelname} - {message}",
+ style="{",
+ datefmt="%Y-%m-%d %H:%M",
+ filename=configuration.config_file_settings['global']['program-log'],
+ )
+
+ logging.info("Connecting to MQTT broker: " + configuration.config_file_settings['global']['mqtt-server'])
+
+ # Start MQTT section
+ mqtt_connected = False
+ while not mqtt_connected:
+ try:
+ mqtt_client, mqtt_handler = start_mqtt(configuration.config_file_settings)
+ mqtt_connected = True
+ except:
+ logging.error("Could not connect to MQTT broker. Retry until success (of until CTRL-C is pressed).")
+ time.sleep(3) # Sleep for 3 seconds
+
+ subscribe_to_mqtt(configuration.config_file_settings, mqtt_client)
+
+ # End MQTT section
+
+ return configuration, mqtt_client, mqtt_handler
+
+def subscribe_to_mqtt(configuration, client):
+
+ for index, entry in enumerate(configuration['mqtt']['subscribe']):
+ logging.debug("Subscribed to MQTT topic: " + str(entry) )
+ client.subscribe(topic=str(entry));
+
+def send_aprs_weather_report(WxData, configuration):
+
+ # Convert sensible SI values to freedom units for APRS weather report
+ wind_direction = int(float(WxData[0].decode('utf-8')))
+ wind_speed = int(2.2369 * float(WxData[1].decode('utf-8')))
+ wind_gust = int(2.2369 * float(WxData[2].decode('utf-8')))
+ rain_lasthour = int(3.93700787 * float(WxData[3].decode('utf-8')))
+ #rain_lasthour = 0
+ rain_24hour = int(3.93700787 * float(WxData[4].decode('utf-8')))
+ #rain_24hour = 0
+ temperature = int(float(WxData[5].decode('utf-8')) * 1.8 + 32)
+ humidity = int(float(WxData[6].decode('utf-8')))
+ if (humidity == 100):
+ humidity = 0;
+ pressure =int(10 * float(WxData[7].decode('utf-8')))
+ luminosity = int(0.0079 * float(WxData[8].decode('utf-8'))) # 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 = configuration['position']
+ aprs_wx_report = '@' + timestamp + 'z' + configuration['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)
+
+ # Send it
+ logging.debug(aprs_wx_report)
+
+ # Define the Bash script and its arguments as a list
+ script = "/usr/sbin/beacon"
+
+ if configuration['digipath'] == 0:
+ arguments = ["-c", f"{configuration['from_call']}", "-d", f"{configuration['destination']}", "-s", f"{configuration['port']}", f"{aprs_wx_report}"]
+ else:
+ #arguments = ["-d", f"{configuration['destination']} {configuration['digipath']}", "-s", f"{configuration['port']}", f"{aprs_wx_report}"]
+ arguments = ["-c", f"{configuration['from_call']}", "-d", f"{configuration['destination']} {configuration['digipath']}", "-s", f"{configuration['port']}", f"{aprs_wx_report}"]
+
+ # Combine the script and its arguments into a single list
+ command = [script] + arguments
+
+ # Run the script
+ logging.debug(command)
+ try:
+ result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ # Check the result
+ if result.returncode == 0:
+ logging.info("Send data to APRS radio.")
+ else:
+ logging.error("Failed to send data to APRS radio.")
+ logging.error(f"Reason: {result.stderr}")
+ except Exception as e:
+ logging.error("Failed to send data to APRS radio.")
+ logging.error(f"Command returned: {e}")
+
+def main():
+
+ Configuration, MqttClient, mqtt_handler = setup()
+ LoopCounter = 0
+
+ while (1):
+ time.sleep(Configuration.config_file_settings['global']['telemetry-interval']) # Sleep for number of seconds set in config.yaml
+
+ # Send data to LoRa radio via external program (/usr/sbin/beacon). Make sure we use all radios defined in the configuration file.
+ for entry in Configuration.config_file_settings['aprs']:
+ send_aprs_weather_report(mqtt_handler.get_aprs_telemetry(), entry)
+ # We cannot send multiple APRS messages in a short period of time, so we wait 3 deconds between messages.
+ time.sleep(3)
+
+if __name__ == '__main__':
+ main()
+