MQTT to APRS weather report bridge added

This commit is contained in:
marcel
2025-09-18 14:46:43 +02:00
parent ce1b59fb6f
commit 0804f82057
5 changed files with 404 additions and 0 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@@ -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}")

View File

@@ -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 <https://www.gnu.org/licenses/>.
"""
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()