Skip to content

Commit

Permalink
add MQTT support (#213)
Browse files Browse the repository at this point in the history
* mqtt overhead prepwork; initial integration of mqtt into plane-alert notifications

* minor fix

* add initial planefence MQTT notification support

* make MQTT datetime format configurable

* typo fix

* use `paho-mqtt` python client instead of `curl`; add more configurable MQTT options

* message quoting

* more string quotation fixes

* quoting experiments

* hopefully this fixes the quoting issues

* bug fix

* minor readme updates

* linting
  • Loading branch information
kx1t authored Dec 8, 2024
1 parent 18db870 commit ddf882c
Show file tree
Hide file tree
Showing 10 changed files with 401 additions and 59 deletions.
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ RUN set -x && \
KEPT_PACKAGES+=(python3-numpy) && \
KEPT_PACKAGES+=(python3-pandas) && \
KEPT_PACKAGES+=(python3-dateutil) && \
KEPT_PACKAGES+=(python3-paho-mqtt) && \
KEPT_PACKAGES+=(jq) && \
KEPT_PACKAGES+=(gnuplot-nox) && \
KEPT_PACKAGES+=(lighttpd) && \
Expand Down Expand Up @@ -95,6 +96,8 @@ RUN set -x && \
chmod a+x /usr/share/socket30003/*.pl && \
rm -rf /run/socket30003/install-* && \
popd && \
# Move the mqtt.py script to an executable directory
mv -f /scripts/mqtt.py /usr/local/bin/mqtt && \
#
# Do some other stuff
echo "alias dir=\"ls -alsv\"" >> /root/.bashrc && \
Expand Down
61 changes: 31 additions & 30 deletions README.md

Large diffs are not rendered by default.

71 changes: 71 additions & 0 deletions rootfs/scripts/mqtt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/python3
'''
# -----------------------------------------------------------------------------------
# Copyright 2020-2024 Ramon F. Kolb - licensed under the terms and conditions
# of GPLv3. The terms and conditions of this license are included with the Github
# distribution of this package, and are also available here:
# https://github.com/sdr-enthusiasts/docker-planefence/
#
# -----------------------------------------------------------------------------------
#
Send a message to a MQTT broker
Syntax: mqtt.py --broker <broker_ip> [--port <port>] --topic <topic> --qos <qos> --client_id <client_id> --message <message>
where:
--broker: The address of the MQTT broker.
--port: The port of the MQTT broker (default is 1883).
--topic: The topic to which the message will be published.
--qos: The Quality of Service level (0, 1, or 2).
--message: The message payload to publish.
--client_id: The MQTT client identifier.
'''

import argparse
import paho.mqtt.client as mqtt # type: ignore

def publish_message(broker, port, topic, qos, message, client_id, username=None, password=None):
# Create an MQTT client instance
client = mqtt.Client(client_id)

# Set username and password if provided
if username and password:
client.username_pw_set(username, password)

try:
# Connect to the MQTT broker
client.connect(broker, port)
# Publish the message
client.publish(topic, payload=message, qos=qos)
print(f"Message '{message}' published to topic '{topic}' with QoS {qos}.")
# Disconnect from the broker
client.disconnect()
except Exception as e:
print(f"Failed to publish message: {e}")

def main():
# Set up command-line arguments
parser = argparse.ArgumentParser(description="MQTT Publish Command Line Tool")
parser.add_argument("--broker", required=True, help="MQTT broker address (e.g., 'localhost').")
parser.add_argument("--port", type=int, default=1883, help="MQTT broker port (default: 1883).")
parser.add_argument("--topic", required=True, help="MQTT topic to publish to.")
parser.add_argument("--qos", type=int, choices=[0, 1, 2], default=0, help="Quality of Service level (default: 0).")
parser.add_argument("--message", required=True, help="Message to publish.")
parser.add_argument("--client_id", default="mqtt_client", help="Client ID for MQTT connection (default: 'mqtt_client').")
parser.add_argument("--username", help="Username for MQTT authentication.")
parser.add_argument("--password", help="Password for MQTT authentication.")
args = parser.parse_args()

# Publish the message
publish_message(
broker=args.broker,
port=args.port,
topic=args.topic,
qos=args.qos,
message=args.message,
client_id=args.client_id,
username=args.username,
password=args.password,
)

if __name__ == "__main__":
main()
11 changes: 11 additions & 0 deletions rootfs/usr/share/plane-alert/plane-alert.conf
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,14 @@ set +a
NOTIFICATION_SERVER=planefence-notifier

PA_MOTD=
#
# ---------------------------------------------------------------------
# MQTT_URL and MQTT_TOPIC are the URL and topic for MQTT notifications. If left empty, no MQTT notifications will be sent
MQTT_URL=""
MQTT_PORT=""
MQTT_TOPIC=""
MQTT_DATETIME_FORMAT=""
MQTT_CLIENT_ID=""
MQTT_QOS=""
MQTT_USERNAME=""
MQTT_PASSWORD=""
89 changes: 89 additions & 0 deletions rootfs/usr/share/plane-alert/plane-alert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
# If not, see https://www.gnu.org/licenses/.
# -----------------------------------------------------------------------------------
#
source /scripts/common
PLANEALERTDIR=/usr/share/plane-alert # the directory where this file and planefence.py are located
# -----------------------------------------------------------------------------------
#
Expand Down Expand Up @@ -384,6 +385,94 @@ then
echo "[$(date)][$APPNAME] $(sed -e 's|\\/|/|g' -e 's|\\n| |g' -e 's|%0A| |g' <<< "${TWITTEXT}")"
fi

# Inject MQTT integration here:
if [[ -n "$MQTT_URL" ]]; then
# do some prep work:
PLANELINE="${ALERT_DICT["${ICAO}"]}"
IFS="," read -ra TAGLINE <<< "$PLANELINE"

unset msg_array
declare -A msg_array

# now put all relevant info into the associative array:
msg_array[icao]="${pa_record[0]//#/}"
msg_array[tail]="${pa_record[1]//#/}"
msg_array[squawk]="${pa_record[10]//#/}"
[[ "${pa_record[10]//#/}" == "7700 " ]] && msg_array[emergency]=true || msg_array[emergency]=false
msg_array[flight]="${pa_record[8]//#/}"
msg_array[operator]="${pa_record[2]//[&\'#]/_}"; msg_array[operator]="${msg_array[operator]//#/}"
msg_array[type]="${pa_record[3]//#/}"
msg_array[datetime]="$(date -d "${pa_record[4]} ${pa_record[5]}" "+${MQTT_DATETIME_FORMAT:-%s}")"
msg_array[tracklink]="${pa_record[9]//globe.adsbexchange.com/"$TRACKSERVICE"}"
msg_array[latitude]="${pa_record[6]}"
msg_array[longitude]="${pa_record[7]}"

# Add any hashtags:
for i in {4..13}; do
(( i >= ${#header[@]} )) && break # don't print headers if they don't exist
if [[ "${header[i]:0:1}" == "$" ]] || [[ "${header[i]:0:2}" == '$#' ]]; then
hdr="${header[i]//[#$]/}"
hdr="${hdr// /_}"
hdr="${hdr,,}"
msg_array[$hdr]="${TAGLINE[i]}"
fi
done

# convert $msg_array[@] into a JSON object:
MQTT_JSON="$(for i in "${!msg_array[@]}"; do printf "{\'%s\':\'%s\'}\n" "$i" "${msg_array[$i]}"; done | jq -sc add)"

# prep the MQTT host, port, etc
unset MQTT_TOPIC MQTT_PORT MQTT_USERNAME MQTT_PASSWORD MQTT_HOST
MQTT_HOST="${MQTT_URL,,}"
MQTT_HOST="${MQTT_HOST##*:\/\/}" # strip protocol header (mqtt:// etc)
while [[ "${MQTT_HOST: -1}" == "/" ]]; do MQTT_HOST="${MQTT_HOST:0: -1}"; done # remove any trailing / from the HOST
if [[ $MQTT_HOST == *"/"* ]]; then MQTT_TOPIC="${MQTT_TOPIC:-${MQTT_HOST#*\/}}"; fi # if there's no explicitly defined topic, then use the URL's topic if that exists
MQTT_TOPIC="${MQTT_TOPIC:-$(hostname)/planealert}" # add default topic if there is still none defined
MQTT_HOST="${MQTT_HOST%%/*}" # remove everything from the first / onward

if [[ $MQTT_HOST == *"@"* ]]; then
MQTT_USERNAME="${MQTT_USERNAME:-${MQTT_HOST%@*}}"
MQTT_PASSWORD="${MQTT_PASSWORD:-${MQTT_USERNAME#*:}}"
MQTT_USERNAME="${MQTT_USERNAME%:*}"
MQTT_HOST="${MQTT_HOST#*@}"
fi
if [[ $MQTT_HOST == *":"* ]]; then MQTT_PORT="${MQTT_PORT:-${MQTT_HOST#*:}}"; fi
MQTT_HOST="${MQTT_HOST%:*}" # finally strip the host so there's only a hostname or ip address


# log the message we are going to send:
echo "[$(date)][$APPNAME] Attempting to send a MQTT notification:"
echo "[$(date)][$APPNAME] MQTT Host: ${MQTT_HOST}"
echo "[$(date)][$APPNAME] MQTT Port: ${MQTT_PORT:-1883}"
echo "[$(date)][$APPNAME] MQTT Topic: ${MQTT_TOPIC}"
echo "[$(date)][$APPNAME] MQTT Client ID: ${MQTT_CLIENT_ID:-$(hostname)}"
if [[ -n "$MQTT_USERNAME" ]]; then echo "[$(date)][$APPNAME] MQTT Username: ${MQTT_USERNAME}"; fi
if [[ -n "$MQTT_PASSWORD" ]]; then echo "[$(date)][$APPNAME] MQTT Password: ${MQTT_PASSWORD}"; fi
if [[ -n "$MQTT_QOS" ]]; then echo "[$(date)][$APPNAME] MQTT QOS: ${MQTT_QOS}"; fi
echo "[$(date)][$APPNAME] MQTT Payload JSON Object: ${MQTT_JSON}"

# send the MQTT message:
# send the MQTT message:
mqtt_string=(--broker "$MQTT_HOST")
if [[ -n "$MQTT_PORT" ]]; then mqtt_string+=(--port "$MQTT_PORT"); fi
mqtt_string+=(--topic \""$MQTT_TOPIC"\")
if [[ -n "$MQTT_QOS" ]]; then mqtt_string+=(--qos "$MQTT_QOS"); fi
mqtt_string+=(--client_id \""${MQTT_CLIENT_ID:-$(hostname)}"\")
if [[ -n "$MQTT_USERNAME" ]]; then mqtt_string+=(--username "$MQTT_USERNAME"); fi
if [[ -n "$MQTT_PASSWORD" ]]; then mqtt_string+=(--password "$MQTT_PASSWORD"); fi
mqtt_string+=(--message "'${MQTT_JSON}'")

# shellcheck disable=SC2068
outputmsg="$(echo ${mqtt_string[@]} | xargs mqtt)"

if [[ "${outputmsg:0:6}" == "Failed" ]] || [[ "${outputmsg:0:5}" == "usage" ]] ; then
echo "[$(date)][$APPNAME] MQTT Delivery Error: ${outputmsg//$'\n'/ }"
else
echo "[$(date)][$APPNAME] MQTT Delivery successful!"
if chk_enabled "$MQTT_DEBUG"; then echo "[$(date)][$APPNAME] Results string: ${outputmsg//$'\n'/ }"; fi
fi
fi

# Inject Mastodon integration here:
if [[ -n "$MASTODON_SERVER" ]]
then
Expand Down
52 changes: 28 additions & 24 deletions rootfs/usr/share/planefence/pf_notify.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/bin/bash
# shellcheck shell=bash disable=SC1091,SC2034,SC2094
# PF_ALERT - a Bash shell script to send a notification to the Notification Server when a plane is detected in the
# user-defined fence area.
#
Expand All @@ -15,6 +16,9 @@
# - Twurl by Twitter: https://github.com/twitter/twurl and https://developer.twitter.com
# These packages may incorporate other software and license terms.
# -----------------------------------------------------------------------------------
#
# Load common script:
source /scripts/common
# Let's see if there is a CONF file that overwrites some of the parameters already defined
[[ "$PLANEFENCEDIR" == "" ]] && PLANEFENCEDIR=/usr/share/planefence
[[ -f "$PLANEFENCEDIR/planefence.conf" ]] && source "$PLANEFENCEDIR/planefence.conf"
Expand Down Expand Up @@ -61,14 +65,13 @@ CSVTMP=/tmp/pf_notify-tmp.csv
# MINTIME is the minimum time (secs) we wait before sending a notification
# to ensure that at least $MINTIME of audio collection (actually limited to the Planefence update runs in this period) to get a more accurste Loudness.

[[ "$TWEET_MINTIME" > 0 ]] && MINTIME=$TWEET_MINTIME || MINTIME=100
(( TWEET_MINTIME > 0 )) && MINTIME=$TWEET_MINTIME || MINTIME=100

# $ATTRIB contains the attribution line at the bottom of the tweet
[[ "x$ATTRIB" == "x" ]] && ATTRIB="#Planefence by kx1t - docker:kx1t/planefence"
ATTRIB="${ATTRIB:-#Planefence by kx1t - docker:kx1t/planefence}"

if [ "$SOCKETCONFIG" != "" ]
then
case "$(grep "^distanceunit=" $SOCKETCONFIG |sed "s/distanceunit=//g")" in
if [[ -n "$SOCKETCONFIG" ]]; then
case "$(grep "^distanceunit=" "$SOCKETCONFIG" |sed "s/distanceunit=//g")" in
nauticalmile)
DISTUNIT="nm"
;;
Expand All @@ -87,7 +90,7 @@ fi
ALTUNIT="ft"
if [ "$SOCKETCONFIG" != "" ]
then
case "$(grep "^altitudeunit=" $SOCKETCONFIG |sed "s/altitudeunit=//g")" in
case "$(grep "^altitudeunit=" "$SOCKETCONFIG" |sed "s/altitudeunit=//g")" in
feet)
ALTUNIT="ft"
;;
Expand Down Expand Up @@ -117,9 +120,9 @@ TWEETDATE=$(date --date="today" '+%y%m%d')
[[ ! -f "$AIRLINECODES" ]] && AIRLINECODES=""

CSVFILE=$CSVNAMEBASE$TWEETDATE$CSVNAMEEXT
#CSVFILE=/tmp/planefence-200526.csv

# make sure there's no stray TMP file around, so we can directly append
[ -f "$CSVTMP" ] && rm "$CSVTMP"
rm -f "$CSVTMP"

#Now iterate through the CSVFILE:
LOG "------------------------------"
Expand All @@ -129,28 +132,29 @@ LOG "CSVFILE=$CSVFILE"
# Get the hashtaggable headers, and figure out of there is a field with a
# custom "$tag" header

if [ -f "$CSVFILE" ]
then
while read CSVLINE
do
XX=$(echo -n $CSVLINE | tr -d '[:cntrl:]')
if [[ -f "$CSVFILE" ]]; then
while read -r CSVLINE; do
XX=$(echo -n "$CSVLINE" | tr -d '[:cntrl:]')
CSVLINE=$XX
unset RECORD
# Read the line, but first clean it up as it appears to have a newline in it
IFS="," read -aRECORD <<< "$CSVLINE"
IFS="," read -ra RECORD <<< "$CSVLINE"
# LOG "${#RECORD[*]} records in the current line: (${RECORD[*]})"
# $TIMEDIFF contains the difference in seconds between the current record and "now".
# We want this to be at least $MINDIFF to avoid tweeting before all noise data is captured
# $TWEET_BEHAVIOR determines if we are looking at the end time (POST -> RECORD[3]) or at the
# start time (not POST -> RECORD[2]) of the observation time
[[ "$TWEET_BEHAVIOR" == "POST" ]] && TIMEDIFF=$(( $(date +%s) - $(date -d "${RECORD[3]}" +%s) )) || TIMEDIFF=$(( $(date +%s) - $(date -d "${RECORD[2]}" +%s) ))
if [[ "$TWEET_BEHAVIOR" == "POST" ]]; then
TIMEDIFF=$(( $(date +%s) - $(date -d "${RECORD[3]}" +%s) ))
else
TIMEDIFF=$(( $(date +%s) - $(date -d "${RECORD[2]}" +%s) ))
fi

if [[ "${RECORD[1]:0:1}" != "@" ]] && [[ $TIMEDIFF -gt $MINTIME ]] && [[ ( "$(grep "${RECORD[0]},@${RECORD[1]}" "$CSVFILE" | wc -l)" == "0" ) || "$TWEETEVERY" == "true" ]]
# shellcheck disable=SC2094
if [[ "${RECORD[1]:0:1}" != "@" ]] && (( TIMEDIFF > MINTIME )) && ! grep -q "${RECORD[0]},@${RECORD[1]}" "$CSVFILE" || chk_enabled "$TWEETEVERY"; then
# ^not tweeted before^ ^older than $MINTIME^ ^No previous occurrence that was notified^ ...or... ^$TWEETEVERY is true^
then

AIRLINE=$(/usr/share/planefence/airlinename.sh ${RECORD[1]#@} ${RECORD[0]} )

AIRLINE=$(/usr/share/planefence/airlinename.sh "${RECORD[1]#@}" "${RECORD[0]}" )

# Create a Notification string that can be patched at the end of a URL:
NOTIF_STRING=""
Expand Down Expand Up @@ -193,20 +197,20 @@ then
then
# If the curl call succeeded, we have a snapshot.png file saved!
TW_MEDIA_ID=$(twurl -X POST -H upload.twitter.com "/1.1/media/upload.json" -f /tmp/snapshot.png -F media | sed -n 's/.*\"media_id\":\([0-9]*\).*/\1/p')
[[ "$TW_MEDIA_ID" > 0 ]] && TWIMG="true" || TW_MEDIA_ID=""
(( TW_MEDIA_ID > 0 )) && TWIMG="true" || TW_MEDIA_ID=""
fi

[[ "$TWIMG" == "true" ]] && echo "Twitter Media ID=$TW_MEDIA_ID" || echo "Twitter screenshot upload unsuccessful for ${RECORD[0]}"

# send a tweet and read the link to the tweet into ${LINK[1]}
if [[ "$TWIMG" == "true" ]]
then
LINK=$(echo `twurl -r "status=$TWEET&media_ids=$TW_MEDIA_ID" /1.1/statuses/update.json` | tee -a /tmp/tweets.log | jq '.entities."urls" | .[] | .url' | tr -d '\"')
LINK="$(twurl -r "status=$TWEET&media_ids=$TW_MEDIA_ID" /1.1/statuses/update.json 2>&1 | tee -a /tmp/tweets.log | jq '.entities."urls" | .[] | .url' | tr -d '\"')"
else
LINK=$(echo `twurl -r "status=$TWEET" /1.1/statuses/update.json` | tee -a /tmp/tweets.log | jq '.entities."urls" | .[] | .url' | tr -d '\"')
LINK="$(twurl -r "status=$TWEET" /1.1/statuses/update.json 2>&1 | tee -a /tmp/tweets.log | jq '.entities."urls" | .[] | .url' | tr -d '\"')"
fi

[[ "${LINK:0:12}" == "https://t.co" ]] && echo "PlaneFence Tweet generated successfully with content: $TWEET" || echo "PlaneFence Tweet error. Twitter returned:\n$(tail -1 /tmp/tweets.log)"
[[ "${LINK:0:12}" == "https://t.co" ]] && echo "PlaneFence Tweet generated successfully with content: $TWEET" || echo "PlaneFence Tweet error. Twitter returned: $(tail -1 /tmp/tweets.log)"
else
LOG "(A tweet would have been sent but \$TWEETON=\"$TWEETON\")"
fi
Expand All @@ -221,7 +225,7 @@ then

# Now write everything back to $CSVTMP
( IFS=','; echo "${RECORD[*]}" >> "$CSVTMP" )
LOG "The record now contains $(IFS=','; echo ${RECORD[*]})"
LOG "The record now contains $(IFS=','; echo "${RECORD[*]}")"

done < "$CSVFILE"
# last, copy the TMP file back to the CSV file
Expand Down
13 changes: 12 additions & 1 deletion rootfs/usr/share/planefence/planefence.conf
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,19 @@ set +a
TRACKSERVICE=
#
# ---------------------------------------------------------------------
# MQTT_URL and MQTT_TOPIC are the URL and topic for MQTT notifications. If left empty, no MQTT notifications will be sent
MQTT_URL=""
MQTT_PORT=""
MQTT_TOPIC=""
MQTT_DATETIME_FORMAT=""
MQTT_CLIENT_ID=""
MQTT_QOS=""
MQTT_USERNAME=""
MQTT_PASSWORD=""
#
# ---------------------------------------------------------------------
# Last, the version. Although you could change this, for tracking purposes, we'd like you to leave it to whatever the
# official version number is. If you fork this software, we'd appreciate if you add on to the version number rather than
# replace the entire number. That way, it's easy to understand from which version you forked. For example, VERSION=3.11-myfork-1.0

VERSION=5.25-dev
VERSION=5.26-mqtt
Loading

0 comments on commit ddf882c

Please sign in to comment.