Compare commits
4 Commits
7604b79b9e
...
main
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0804f82057 | ||
![]() |
ce1b59fb6f | ||
![]() |
f5a6937786 | ||
![]() |
9e21f6a011 |
@@ -1,6 +1,6 @@
|
||||
# Python ModBus test program
|
||||
# Python MQTT to pe1rxf telemetry bridge
|
||||
|
||||
A simple test program to test ModBus peripherals connected to a serial port.
|
||||
Forwards MQTT messages to AX.25 according to the PE1RXF telemetry protocol
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -15,6 +15,8 @@ Edit config.yaml.
|
||||
- sys
|
||||
- logging
|
||||
|
||||
AX.25 stack on Linux. "/usr/sbin/beacon" installed.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2025 M.T. Konstapel
|
36
software/mqqt_to_pe1rxf_telemetry/config.yaml
Normal file
36
software/mqqt_to_pe1rxf_telemetry/config.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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: 10 # 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)
|
||||
to_call: PE1RXF-1 # Call of the receiver
|
||||
destination: APZMDM # APRS destination
|
||||
digipath: 0 # Digipeater path for weather reports (0 = no path)
|
||||
# - 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 data to transmit over AX25. The order of the items is the order the data is combined to the telemetry string.
|
||||
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/temperature_pressure_sensor
|
||||
- mees_electronics/4d45000000000002/status_bits
|
||||
- mees_electronics/4d45000000000002/luminosity
|
90
software/mqqt_to_pe1rxf_telemetry/config_reader.py
Normal file
90
software/mqqt_to_pe1rxf_telemetry/config_reader.py
Normal 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['to_call']
|
||||
tmp = entry['destination']
|
||||
tmp = entry['digipath']
|
||||
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
|
10
software/mqqt_to_pe1rxf_telemetry/mqtt_callbacks.py
Normal file
10
software/mqqt_to_pe1rxf_telemetry/mqtt_callbacks.py
Normal 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}")
|
211
software/mqqt_to_pe1rxf_telemetry/mqtt_to_pe1rxf_telemetry.py
Normal file
211
software/mqqt_to_pe1rxf_telemetry/mqtt_to_pe1rxf_telemetry.py
Normal file
@@ -0,0 +1,211 @@
|
||||
""""
|
||||
MQTT to pe1rxf telemetry for Mees Electronics sensors.
|
||||
Subscribes to MQTT broker entries, combines the values and sends
|
||||
the data as pe1rxf telemetry 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
|
||||
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.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.')
|
||||
|
||||
# Format the pe1rxf telemetry string
|
||||
def get_aprs_telemetry_string(self):
|
||||
data_string = []
|
||||
for entry in self.aprs_telemetry_data:
|
||||
data_string.append(entry.decode('utf-8'))
|
||||
data_string = ','.join(data_string)
|
||||
|
||||
return data_string
|
||||
|
||||
|
||||
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));
|
||||
|
||||
# It is not possible to send multiple beacons in a short time. Call this function not faster than every 3 seconds or so.
|
||||
def send_data_to_aprs(weather_data, configuration):
|
||||
|
||||
to_call = configuration['to_call'].ljust(9)[:9]
|
||||
|
||||
# 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":{to_call}:{weather_data}"]
|
||||
else:
|
||||
#arguments = ["-d", f"{configuration['destination']} {configuration['digipath']}", "-s", f"{configuration['port']}", f":{to_call}:{weather_data}"]
|
||||
arguments = ["-c", f"{configuration['from_call']}", "-d", f"{configuration['destination']} {configuration['digipath']}", "-s", f"{configuration['port']}", f":{to_call}:{weather_data}"]
|
||||
|
||||
# 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_data_to_aprs(mqtt_handler.get_aprs_telemetry_string(), entry)
|
||||
logging.debug(mqtt_handler.get_aprs_telemetry_string())
|
||||
# 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()
|
||||
|
27
software/mqtt_to_aprs_weather_report/README.md
Normal file
27
software/mqtt_to_aprs_weather_report/README.md
Normal 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.
|
44
software/mqtt_to_aprs_weather_report/config.yaml
Normal file
44
software/mqtt_to_aprs_weather_report/config.yaml
Normal 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
|
90
software/mqtt_to_aprs_weather_report/config_reader.py
Normal file
90
software/mqtt_to_aprs_weather_report/config_reader.py
Normal 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
|
10
software/mqtt_to_aprs_weather_report/mqtt_callbacks.py
Normal file
10
software/mqtt_to_aprs_weather_report/mqtt_callbacks.py
Normal 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}")
|
@@ -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()
|
||||
|
33
software/rs485_client_to_mqtt/README.md
Normal file
33
software/rs485_client_to_mqtt/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# RS485 ModBus client to MQTT bridge
|
||||
|
||||
Scans the configured ModBus for Mees Electronics sensors and publishes the sensor data to the configured MQTT broker.
|
||||
|
||||
The Mees Electronics sensors are almost plug-and-play. You just have to set the sensor to a unique address and add this address to the config.yaml file. The description entry in the YAML file is only there for convenience.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit config.yaml.
|
||||
|
||||
The file modbus_registers.yaml contains the Mees Electronics register definitions of the various sensors. The newest definition file can be downloaded from the git repository.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python3
|
||||
- minimalmodbus
|
||||
- json
|
||||
- time
|
||||
- sys
|
||||
- logging
|
||||
- os
|
||||
- pathlib
|
||||
- paho.mqtt.client
|
||||
- pyyaml
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2025 M.T. Konstapel (https://meezenest.nl/mees)
|
||||
|
||||
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.
|
@@ -1,6 +1,5 @@
|
||||
# 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)
|
||||
@@ -14,6 +13,7 @@ global:
|
||||
modbus:
|
||||
#port: /dev/serial/by-path/platform-3f980000.usb-usb-0:1.3:1.0-port0 # USB port to which RS-485 dongle is connected
|
||||
port: /dev/ttyUSB0
|
||||
poll_speed: 60 # Polling speed in seconds
|
||||
|
||||
# Modbus servers settings
|
||||
modbus_servers:
|
||||
@@ -21,15 +21,3 @@ modbus_servers:
|
||||
description: Dual temperature sensor
|
||||
- address: 1
|
||||
description: Dummy
|
||||
|
||||
aprs:
|
||||
- port: ax0 # Linux AX.25 port to which APRS weather report is sent
|
||||
call: PE1RXF-13 # Call from which transmissions are made (can be a different call from the call assigned to the AX.25 port)
|
||||
destination: APZMDM # APRS destination
|
||||
digi_path: 0 # Digipeater path for weather reports (0 = no path)
|
||||
interval: 30
|
||||
- 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
|
||||
digi_path: WIDE2-1 # Digipeater path for weather reports (0 = no path)
|
||||
interval: 30
|
@@ -38,9 +38,6 @@ class config_reader:
|
||||
if self.test_modbus_servers_settings() == 0:
|
||||
return 0
|
||||
|
||||
if self.aprs_settings() == 0:
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
def read_config_file (self):
|
||||
@@ -73,6 +70,7 @@ class config_reader:
|
||||
# Test is all expected settings are present
|
||||
try:
|
||||
tmp = self.config_file_settings['modbus']['port']
|
||||
tmp = self.config_file_settings['modbus']['poll_speed']
|
||||
except:
|
||||
print ("Error in the modbus section of the configuration file.")
|
||||
return 0
|
||||
@@ -89,17 +87,3 @@ class config_reader:
|
||||
return 0
|
||||
else:
|
||||
return 1
|
||||
|
||||
def aprs_settings(self):
|
||||
for entry in self.config_file_settings['aprs']:
|
||||
try:
|
||||
tmp = tmp = entry['port']
|
||||
tmp = entry['call']
|
||||
tmp = entry['destination']
|
||||
tmp = entry['digi_path']
|
||||
tmp = entry['interval']
|
||||
except:
|
||||
print ("Error in the aprs section of the configuration file.")
|
||||
return 0
|
||||
else:
|
||||
return 1
|
107
software/rs485_client_to_mqtt/modbus_control.py
Normal file
107
software/rs485_client_to_mqtt/modbus_control.py
Normal file
@@ -0,0 +1,107 @@
|
||||
""""
|
||||
ModBus control 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 minimalmodbus
|
||||
import serial
|
||||
|
||||
class ModBusController(minimalmodbus.Instrument):
|
||||
"""Instrument class for Epever Charge Controllers.
|
||||
|
||||
Args:
|
||||
* portname (str): port name
|
||||
* slaveaddress (int): slave address in the range 1 to 247
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, portname, slaveaddress):
|
||||
minimalmodbus.Instrument.__init__(self, portname, slaveaddress)
|
||||
self.serial.baudrate = 9600
|
||||
self.serial.bytesize = 8
|
||||
self.serial.parity = serial.PARITY_NONE
|
||||
self.serial.stopbits = 1
|
||||
self.serial.timeout = 0.3
|
||||
self.mode = minimalmodbus.MODE_RTU
|
||||
self.clear_buffers_before_each_transaction = True
|
||||
self.close_port_after_each_call = False
|
||||
|
||||
self.watchdog_toggle_bit = 1
|
||||
|
||||
# Read the device ID
|
||||
def get_id(self):
|
||||
device_id = [None]*4
|
||||
device_id[0] = self.read_register(1000, 0, 4, False)
|
||||
device_id[1] = self.read_register(1001, 0, 4, False)
|
||||
device_id[2] = self.read_register(1002, 0, 4, False)
|
||||
device_id[3] = self.read_register(1003, 0, 4, False)
|
||||
|
||||
return device_id
|
||||
|
||||
# Read the device type
|
||||
def get_type(self):
|
||||
device_type = self.read_register(1004, 0, 4, False)
|
||||
|
||||
return device_type
|
||||
|
||||
# Get the 40 character long description string from ModBus device
|
||||
def get_type_string(self):
|
||||
register_offset = 1006 # ModBus register for text string starts at 1006
|
||||
device_type_register = [None]*20 # ModBus register for text string is 20 integers long
|
||||
device_type_string = "" # Store the decoded string here
|
||||
|
||||
for index, entry in enumerate(device_type_register):
|
||||
# Read Type string registers
|
||||
device_type_register[index] = self.read_register(index + register_offset, 0, 4, False)
|
||||
# Convert to ASCII string
|
||||
device_type_string += chr((device_type_register[index] & 0xFF00) >> 8)
|
||||
device_type_string += chr(device_type_register[index] & 0xFF)
|
||||
|
||||
return device_type_string.strip()
|
||||
|
||||
# Ask the device how many input registers it has
|
||||
def get_number_of_input_registers(self):
|
||||
device_number_of_input_registers = self.read_register(1026, 0, 4, False)
|
||||
|
||||
return device_number_of_input_registers
|
||||
|
||||
# Read all the input registers
|
||||
def read_all_input_registers(self):
|
||||
|
||||
number_of_input_registers = self.get_number_of_input_registers()
|
||||
input_register = [None]*number_of_input_registers
|
||||
|
||||
for index in range(number_of_input_registers):
|
||||
input_register[index] = self.read_register(index, 0, 4, False)
|
||||
|
||||
return input_register
|
||||
|
||||
def set_watchdog(self):
|
||||
self.write_bit(0, 1, 5)
|
||||
|
||||
def reset_watchdog(self):
|
||||
self.write_bit(0, 0, 5)
|
||||
|
||||
# Toggle the devices watchdog
|
||||
def toggle_watchdog(self):
|
||||
if self.watchdog_toggle_bit:
|
||||
self.set_watchdog()
|
||||
self.watchdog_toggle_bit = 0
|
||||
else:
|
||||
self.reset_watchdog()
|
||||
self.watchdog_toggle_bit = 1
|
||||
|
||||
|
@@ -0,0 +1,56 @@
|
||||
""""
|
||||
ModBus definition file reader routines
|
||||
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 yaml
|
||||
from yaml.loader import SafeLoader
|
||||
|
||||
class definition_file_reader:
|
||||
|
||||
# initiate class: define name configuration files
|
||||
def __init__(self, definition_file):
|
||||
self.definition_file = definition_file
|
||||
|
||||
def read_settings(self):
|
||||
|
||||
if self.read_definition_file() == 0:
|
||||
return 0
|
||||
|
||||
if self.test_definition_file() == 0:
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
def read_definition_file (self):
|
||||
try:
|
||||
with open(self.definition_file) as f:
|
||||
self.definition_file_data = yaml.load(f, Loader=SafeLoader)
|
||||
except:
|
||||
print ("Definition file ./" + self.definition_file + " not found or syntax error in file.")
|
||||
return 0
|
||||
else:
|
||||
return 1
|
||||
|
||||
# Test if all settings are pressent
|
||||
def test_definition_file(self):
|
||||
# Test is all expected settings are present
|
||||
try:
|
||||
tmp = self.definition_file_data['devices']
|
||||
except:
|
||||
print ("Error in the ModBus definition file.")
|
||||
return 0
|
||||
else:
|
||||
return 1
|
30
software/rs485_client_to_mqtt/modbus_registers.yaml
Normal file
30
software/rs485_client_to_mqtt/modbus_registers.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file defines the Mees Electronincs ModBus device registers
|
||||
|
||||
devices:
|
||||
- device_type: 1
|
||||
input_registers: 6 # The number of available input registers, starting from offset 40
|
||||
input_register_names: # Description, unit
|
||||
- [Temperature A, °C]
|
||||
- [Temperature B , °C]
|
||||
- [Minimum temperature A, °C]
|
||||
- [Minimum temperature B, °C]
|
||||
- [Maximum temperature A, °C]
|
||||
- [Maximum temperature B, °C]
|
||||
- device_type: 2
|
||||
input_registers: 15 # The number of available input registers, starting from offset 40
|
||||
input_register_names: # Description, unit, scaling 0 = as is, 1 = decimal one position to the left, 2 = decimal two positions to the left, enz.
|
||||
- [weater_station_id, '', 0]
|
||||
- [wind_direction, °, 1]
|
||||
- [wind_speed, 'km/h', 2]
|
||||
- [wind_gust, 'km/h', 2]
|
||||
- [temperature, °C, 2]
|
||||
- [rain_last_hour, 'l/m2', 2]
|
||||
- [rain_last_24 hours, 'l/m2', 2]
|
||||
- [rain_since_midnight, 'l/m2', 2]
|
||||
- [humidity, '%', 2]
|
||||
- [barometric_pressure, hPa, 1]
|
||||
- [luminosity, 'W/m2', 0]
|
||||
- [snow_fall, NA, 0]
|
||||
- [raw_rainfall_counter, mm, 0]
|
||||
- [temperature_pressure_sensor, °C, 2]
|
||||
- [status_bits, '', 0]
|
@@ -1,5 +1,7 @@
|
||||
""""
|
||||
ModBus test routines
|
||||
ModBus client program. Scans the RS-485 ModBus for Mees Electronics
|
||||
sensors and publishes the sensor data to an MQTT broker.
|
||||
|
||||
Copyright (C) 2025 M.T. Konstapel
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
@@ -21,8 +23,8 @@ import sys
|
||||
import logging
|
||||
import minimalmodbus
|
||||
import datetime
|
||||
import subprocess
|
||||
import os
|
||||
import random
|
||||
|
||||
from pathlib import Path
|
||||
from modbus_control import ModBusController
|
||||
@@ -41,13 +43,14 @@ 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="myPy",
|
||||
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="myPy",
|
||||
client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=client_id,
|
||||
transport=mqtt_transport,
|
||||
protocol=mqtt.MQTTv311,
|
||||
clean_session=True)
|
||||
@@ -76,16 +79,11 @@ def start_mqtt(config):
|
||||
|
||||
client.loop_start();
|
||||
|
||||
# Debug code
|
||||
# while 1:
|
||||
# client.publish(mqtt_topic,'Cedalo Mosquitto is awesome',2,properties=properties);
|
||||
# time.sleep(1)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def setup():
|
||||
config_file = "config.yml"
|
||||
config_file = "config.yaml"
|
||||
definition_file = "modbus_registers.yaml"
|
||||
|
||||
# Read the configuration file
|
||||
@@ -177,36 +175,7 @@ def data_logger(data, configuration):
|
||||
except:
|
||||
logging.warning("Could not write to file: " + new_filename)
|
||||
|
||||
# It is not possible to send multiple beacons in a short time. Call this function not faster than every 3 seconds or so.
|
||||
def send_data_to_aprs(weather_data, configuration):
|
||||
|
||||
# Define the Bash script and its arguments as a list
|
||||
script = "/usr/sbin/beacon"
|
||||
|
||||
if configuration['digi_path'] == 0:
|
||||
arguments = ["-d", f"{configuration['destination']}", "-s", "ax1", ":PE1RXF-3 :test2"]
|
||||
else:
|
||||
arguments = ["-d", f"{configuration['destination']} {configuration['digi_path']}", "-s", "ax1", ":PE1RXF-3 :test3"]
|
||||
|
||||
# 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 send_data_to_mqtt(data, configuration, modbus_registers, mqtt_client):
|
||||
def send_data_to_mqtt(data, configuration, modbus_registers, MqttClient):
|
||||
|
||||
mqtt_top_topic = []
|
||||
mqtt_full_topic = []
|
||||
@@ -239,8 +208,7 @@ def send_data_to_mqtt(data, configuration, modbus_registers, mqtt_client):
|
||||
# Go through every input register and match the unit and description with the value
|
||||
for index2, entry2 in enumerate(data['InputRegisters']):
|
||||
# Scale values
|
||||
entry2 = entry2/ (10^entry1['input_register_names'][index2][2])
|
||||
|
||||
entry2 = entry2/ pow(10, entry1['input_register_names'][index2][2])
|
||||
message_topic = entry1['input_register_names'][index2][0]
|
||||
mqtt_message = str(round(entry2,1))
|
||||
mqtt_full_topic = mqtt_top_topic + message_topic
|
||||
@@ -265,81 +233,71 @@ def ReconnectModBus(configuration):
|
||||
|
||||
logging.info("Reconnected to ModBus dongle.")
|
||||
|
||||
Configuration, Controller, ModbusAddresses, ModbusRegisters, MqttClient = setup()
|
||||
LoopCounter = 0
|
||||
def main():
|
||||
|
||||
while (1):
|
||||
time.sleep(3) # Sleep for 3 seconds
|
||||
Configuration, Controller, ModbusAddresses, ModbusRegisters, MqttClient = setup()
|
||||
LoopCounter = 0
|
||||
|
||||
# Send APRS telemetry every 10 cycles = every 10 minutes
|
||||
'''
|
||||
LoopCounter = LoopCounter + 1
|
||||
if LoopCounter >= 1:
|
||||
while (1):
|
||||
time.sleep(Configuration.config_file_settings['modbus']['poll_speed']) # Sleep for number of seconds given 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_data_to_aprs(1, entry)
|
||||
# We cannot send multiple APRS messages in a short period of time, so we wait 3 deconds between messages.
|
||||
time.sleep(3)
|
||||
ModBusData={}
|
||||
|
||||
LoopCounter = 0
|
||||
'''
|
||||
ModBusData={}
|
||||
# Loop through all configured ModBus devices and try to read the sensor data
|
||||
for index, current_modbus_device in enumerate(ModbusAddresses):
|
||||
logging.debug("Reading device on ModBus address: " + str(current_modbus_device))
|
||||
|
||||
# Loop through all configured ModBus devices and try to read the sensor data
|
||||
for index, current_modbus_device in enumerate(ModbusAddresses):
|
||||
logging.debug("Reading device on ModBus address: " + str(current_modbus_device))
|
||||
|
||||
try:
|
||||
ModBusData['DateTime'] = datetime.datetime.now().replace(microsecond=0).isoformat()
|
||||
ModBusData['ID'] = Controller[index].get_id()
|
||||
if ModBusData['ID'][0] == 0x4D45:
|
||||
ModBusData['Type'] = Controller[index].get_type()
|
||||
ModBusData['TypeString'] = Controller[index].get_type_string()
|
||||
ModBusData['InputRegisters'] = Controller[index].read_all_input_registers()
|
||||
|
||||
# Keep the watchdog from resetting the ModBusdevice
|
||||
#Controller[index].toggle_watchdog()
|
||||
|
||||
NoError = True
|
||||
except minimalmodbus.NoResponseError:
|
||||
# Handle communication timeout
|
||||
logging.warning("No response from the instrument on ModBus address: " + str(current_modbus_device))
|
||||
NoError = False
|
||||
except minimalmodbus.InvalidResponseError:
|
||||
# Handle invalid Modbus responses
|
||||
logging.warning("Invalid response from the instrument on ModBus address: " + str(current_modbus_device))
|
||||
NoError = False
|
||||
except minimalmodbus.SlaveReportedException as e:
|
||||
# Handle errors reported by the slave device
|
||||
logging.error(f"The instrument reported an error: {e}")
|
||||
NoError = False
|
||||
except minimalmodbus.ModbusException as e:
|
||||
# Handle all other Modbus-related errors
|
||||
logging.error(f"Modbus error: {e}")
|
||||
NoError = False
|
||||
except Exception as e:
|
||||
# Handle unexpected errors, probably I/O error in USB/serial
|
||||
logging.error(f"Unexpected error: {e}" + ", serial port " + Configuration.config_file_settings['modbus']['port'] + " not available while reading device on ModBus address " + str(ModbusAddresses[index]) + ". Try to reconnect.")
|
||||
Controller[index].serial.close()
|
||||
ReconnectModBus(Configuration)
|
||||
NoError = False
|
||||
|
||||
if NoError == True:
|
||||
try:
|
||||
logging.debug("Found Mees Electronics sensor on ModBus address " + str(current_modbus_device))
|
||||
logging.debug("Serial number: " + hex(ModBusData['ID'][1]) + " " + hex(ModBusData['ID'][2]) + " " + hex(ModBusData['ID'][3]))
|
||||
logging.debug("Device type: " + str(ModBusData['Type']) + " (" + ModBusData['TypeString'] + ")")
|
||||
logging.debug (ModBusData['InputRegisters'])
|
||||
#logging.debug (json.dumps(ModBusData, indent=1, sort_keys=False))
|
||||
ModBusData['DateTime'] = datetime.datetime.now().replace(microsecond=0).isoformat()
|
||||
ModBusData['ID'] = Controller[index].get_id()
|
||||
if ModBusData['ID'][0] == 0x4D45:
|
||||
ModBusData['Type'] = Controller[index].get_type()
|
||||
ModBusData['TypeString'] = Controller[index].get_type_string()
|
||||
ModBusData['InputRegisters'] = Controller[index].read_all_input_registers()
|
||||
|
||||
except:
|
||||
logging.warning("Modbus device type " + str(ModBusData['Type']) + " not found in register definition file. Ignoring sensor data.")
|
||||
# Keep the watchdog from resetting the ModBusdevice
|
||||
Controller[index].toggle_watchdog()
|
||||
|
||||
# Log sensor data to file if enabled in configuration file
|
||||
data_logger(ModBusData, Configuration)
|
||||
NoError = True
|
||||
except minimalmodbus.NoResponseError:
|
||||
# Handle communication timeout
|
||||
logging.warning("No response from the instrument on ModBus address: " + str(current_modbus_device))
|
||||
NoError = False
|
||||
except minimalmodbus.InvalidResponseError:
|
||||
# Handle invalid Modbus responses
|
||||
logging.warning("Invalid response from the instrument on ModBus address: " + str(current_modbus_device))
|
||||
NoError = False
|
||||
except minimalmodbus.SlaveReportedException as e:
|
||||
# Handle errors reported by the slave device
|
||||
logging.error(f"The instrument reported an error: {e}")
|
||||
NoError = False
|
||||
except minimalmodbus.ModbusException as e:
|
||||
# Handle all other Modbus-related errors
|
||||
logging.error(f"Modbus error: {e}")
|
||||
NoError = False
|
||||
except Exception as e:
|
||||
# Handle unexpected errors, probably I/O error in USB/serial
|
||||
logging.error(f"Unexpected error: {e}" + ", serial port " + Configuration.config_file_settings['modbus']['port'] + " not available while reading device on ModBus address " + str(ModbusAddresses[index]) + ". Try to reconnect.")
|
||||
Controller[index].serial.close()
|
||||
ReconnectModBus(Configuration)
|
||||
NoError = False
|
||||
|
||||
# Send sensor data to MQTT broker
|
||||
send_data_to_mqtt(ModBusData, Configuration, ModbusRegisters.definition_file_data, MqttClient)
|
||||
if NoError == True:
|
||||
try:
|
||||
logging.debug("Found Mees Electronics sensor on ModBus address " + str(current_modbus_device))
|
||||
logging.debug("Serial number: " + hex(ModBusData['ID'][1]) + " " + hex(ModBusData['ID'][2]) + " " + hex(ModBusData['ID'][3]))
|
||||
logging.debug("Device type: " + str(ModBusData['Type']) + " (" + ModBusData['TypeString'] + ")")
|
||||
#logging.debug (ModBusData['InputRegisters'])
|
||||
#logging.debug (json.dumps(ModBusData, indent=1, sort_keys=False))
|
||||
|
||||
except:
|
||||
logging.warning("Modbus device type " + str(ModBusData['Type']) + " not found in register definition file. Ignoring sensor data.")
|
||||
|
||||
# Log sensor data to file if enabled in configuration file
|
||||
data_logger(ModBusData, Configuration)
|
||||
|
||||
# Send sensor data to MQTT broker
|
||||
send_data_to_mqtt(ModBusData, Configuration, ModbusRegisters.definition_file_data, MqttClient)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
@@ -1,15 +0,0 @@
|
||||
2025-08-01 16:23 - INFO - Succesfully read the configuration from file: config.yml
|
||||
2025-08-01 16:23 - INFO - Program output will be logged to file: /home/marcel/rs458.log
|
||||
2025-08-01 16:23 - INFO - Sensor data will be logged to file: /home/marcel/weather_station_data.log
|
||||
2025-08-01 16:23 - INFO - Using RS485 port : /dev/ttyUSB0
|
||||
2025-08-01 16:23 - INFO - Succesfully read the ModBus register definitions from file: modbus_registers.yaml
|
||||
2025-08-01 16:23 - INFO - Using device: Dual temperature sensor on ModBus address: 14
|
||||
2025-08-01 16:23 - INFO - Using device: Dummy on ModBus address: 1
|
||||
2025-08-01 16:23 - WARNING - No response from the instrument on ModBus address: 1
|
||||
2025-08-01 16:24 - INFO - Succesfully read the configuration from file: config.yml
|
||||
2025-08-01 16:24 - INFO - Program output will be logged to file: /home/marcel/rs458.log
|
||||
2025-08-01 16:24 - INFO - Sensor data will be logged to file: /home/marcel/weather_station_data.log
|
||||
2025-08-01 16:24 - INFO - Using RS485 port : /dev/ttyUSB0
|
||||
2025-08-01 16:24 - INFO - Succesfully read the ModBus register definitions from file: modbus_registers.yaml
|
||||
2025-08-01 16:24 - INFO - Using device: Dual temperature sensor on ModBus address: 14
|
||||
2025-08-01 16:24 - INFO - Using device: Dummy on ModBus address: 1
|
@@ -1,2 +0,0 @@
|
||||
{"DateTime": "2025-08-01T16:55:39", "ID": [19781, 0, 0, 1], "Type": 1, "TypeString": "Dual temperature sensor", "InputRegisters": [20, 21, 22, 24, 23, 25]}
|
||||
{"DateTime": "2025-08-01T16:55:43", "ID": [19781, 0, 0, 1], "Type": 1, "TypeString": "Dual temperature sensor", "InputRegisters": [20, 21, 22, 24, 23, 25]}
|
Reference in New Issue
Block a user