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() +