-
Notifications
You must be signed in to change notification settings - Fork 0
/
hamqtt.py
402 lines (319 loc) · 13.8 KB
/
hamqtt.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
#!/usr/bin/python3
#
################################################################################
#
# HamMQTT Reprocessor
# - Sbscribes to an MQTT publication from a Ham radio (Yes!)
# - Uses this data to publish an MQTT string which switches lights.
# - Can also publish a text to the transmitter by way of response to queries.
#
# Ken McMullan, M7KCM
#
################################################################################
#
# Don't forget to:
# sudo apt install python-pip
# pip install paho-mqtt
#
################################################################################
#
# 10-Mar-2024
# A subset fork of my home MQTT post-processor.
# The MQTT string originates as a DTMF sequence sent by a ham radio, gets
# translated into an MQTT packet by TASMOTA, and published to a broker.
# Here, we subscribe to, and act on the MQTT data from the broker. For this
# basic demonstration, if the packet contains "data" and the data is "1" or
# "0", a packet is published which causes my studly light to be switched on or
# off as appropriate.
#
# 23-Jun-2024
# The program subscribes to an MQTT topic, which contains a DTMF sequence. The
# sequence is nominally sourced from a device like a phone or a ham radio. If
# the sequences starts with "*" it's a command; if it starts "#", it's a query.
# A list of recognised sequences "cmds" is stored, along with the actions upon
# receipt.
# If the DTMF sequence is recognised a particular MQTT topic and data are
# published. Additionally, an MQTT topic is subscribed to, which would contain
# a response. A JSON string indicates where to find the response within the
# topic.
#
# 19-Jul-2024
# Fixed problem where status of a query getting properly extracted.
# Removed queries from the "sought" list once they've been received.
#
################################################################################
#
# Future
#
# Move the command / query specifications into a seperate text file.
#
# Allow the creation of a list of stuff to monitor. So (eg) each time the
# bathroom light changes, the state is always transmitted, regardless where the
# command came from.
#
# Create a webserver, which allows the creation / editing of the dictionary of
# commands / responses.
#
# Modify the hardware / software to allow querying and setting of specific
# features of the radio transceiver, eg output power, received signal strength,
# bettery level.
#
################################################################################
#
# Issues
#
# Need to UNSUBSCRIBE once the message has been received, as some MQTT status
# messaegs contain multiple sensors and all sensors previously queued will be
# returned.
#
################################################################################
#
# Record Structure:
#
# Ddtmf: the DTMF string for comparison agains the strings received.
# Ddesc: a (phoneticised) spoken language interpretation of the sensor.
# Dtopic: the MQTT topic to be published upon reecipt of "Ddtmf" string.
# Ddata: the data to be published on the above topic.
# DrespTopic: the topic to be subscribed to, which contains the response.
# DrespKey: the JSON pinpointing the response.
#
################################################################################
MQTTserver="192.168.1.12"
MQTTuser="device"
MQTTpass="equallyspecial"
# declaring these as constants should reduce the possibility of human error in creating the dictionary
Ddtmf = "dtmf" # DTMF sequence from transmitter
Ddesc = "desc" # spoken description
Dtopic = "Dtopic" # MQTT command
Ddata = "Ddata" # MQTT command data
DrespTopic = "DtopicR" # MQTT response topic
DrespKey = "Dkey" # MQTT response key
cmds=[
{Ddtmf:"#100", Ddesc:"outsied temperature", Dtopic:"garageweather/cmnd/status", Ddata:"8",
DrespTopic: "garageweather/stat/STATUS8", DrespKey:"StatusSNS.SI7021-14.Temperature"},
{Ddtmf:"#101", Ddesc:"downstairs temperature", Dtopic:"cloakroom/cmnd/status", Ddata:"8",
DrespTopic: "cloakroom/stat/STATUS8", DrespKey:"StatusSNS.SI7021.Temperature"},
{Ddtmf:"#102", Ddesc:"up stairs temperature", Dtopic:"hottank/cmnd/status", Ddata:"8",
DrespTopic: "hottank/stat/STATUS8", DrespKey:"StatusSNS.SI7021.Temperature"},
{Ddtmf:"#200", Ddesc:"bath rume, mane light", Dtopic:"ch4_01/cmnd/status", Ddata:"11",
DrespTopic: "ch4_01/stat/STATUS11", DrespKey:"StatusSTS.POWER1"},
{Ddtmf:"#201", Ddesc:"bath room, merror light", Dtopic:"ch4_01/cmnd/status", Ddata:"11",
DrespTopic: "ch4_01/stat/STATUS11", DrespKey:"StatusSTS.POWER2"},
{Ddtmf:"*2000", Ddesc:"bath rume, mane light, off", Dtopic:"ch4_01/cmnd/POWER1", Ddata:"off"},
{Ddtmf:"*2001", Ddesc:"bath rume, mane light, on", Dtopic:"ch4_01/cmnd/POWER1", Ddata:"on"},
{Ddtmf:"*2010", Ddesc:"bath rume, merror light off", Dtopic:"ch4_01/cmnd/POWER2", Ddata:"off"},
{Ddtmf:"*2011", Ddesc:"bath rume, merror light on", Dtopic:"ch4_01/cmnd/POWER2", Ddata:"on"}
] # commands from the transmitter
soughtList=[] # list of response topics for which we are waiting
subList=[] # list of topics to which we've subscribed
timerDisplayEnabled = False # no display output
logFileError = "/home/ken/python/hamqtt/hamerror.log"
baseTopic = "hamqtt"
subTopicStat = baseTopic + "/stat" # subscribe to this
subTopicNet = baseTopic + "/net" # subscribe to this
subTopicRx = baseTopic + "/rx" # subscribe to this
subTopicTx = baseTopic + "/tx" # to publish a message from me
# This function causes PAHO errors to be logged to screen.
logToScreen = False # if False, PAHO callbacks do not display errors.
from time import sleep, monotonic
import sys
import subprocess
import datetime
import signal
import paho.mqtt.client as mqtt
import csv
import json
mqttc = mqtt.Client()
MQTTconnected = False # global in the context of on_connect and on_disconnect.
def compoundIf(bool, true, false):
if (bool): ret = true
else: ret = false
return ret
def stripLast(txt,delimiter=".",beg=0): # strips everything from and including the last instance of delimiter
strtxt = str(txt)
return strtxt[:strtxt.rfind(delimiter,beg)]
def tryGet(payload, key, subkey = ""):
try:
ret = payload.get(key)
except:
ret = ""
if subkey != "":
try:
ret = ret.get(subkey)
except:
ret = ""
return ret
def getNestedDictKey(dic, dotted_key): # Function to get value using dotted string
# print(f"getNestedDictKey({dic}, {dotted_key.split('.')}")
ret = dic
try:
for key in dotted_key.split('.'): # recursively search the record for successive dotted keys
ret = ret[key] # search the newly extracted record for the next key
# print(f"KEY {key}")
except:
ret = ""
return ret
def politeExit(exitMsg):
out = stripLast(datetime.datetime.now(),".")
appendFile(logFileError, out + " " + exitMsg)
mqttc.publish(baseTopic + "/status", "Stopped")
mqttc.will_set(baseTopic + "/LWT", payload="AWOL")
mqttc.loop_stop()
print(exitMsg)
# store the original sigint and sigterm handlers
original_sigint = signal.getsignal(signal.SIGINT)
original_sigterm = signal.getsignal(signal.SIGTERM)
def signal_handler(incoming, frame):
if (incoming == signal.SIGTERM):
signal.signal(signal.SIGTERM, original_sigterm)
politeExit("Received SIGTERM")
if (incoming == signal.SIGINT):
signal.signal(signal.SIGINT, original_sigint)
politeExit("Ctrl-C pressed")
sys.exit(0)
# set new handlers for ctrl-C and terminate
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# The callback for when the client receives a CONNACK response from the MQTT broker.
def on_connect(client, userdata, flags, rc):
global MQTTconnected
MQTTconnected = True
msg = "Connected with result code "+str(rc)
msg = stripLast(datetime.datetime.now(),".") + " " + msg
appendFile(logFileError, msg)
print(msg)
# Subscribing in on_connect() means that if we lose the connection and
# reconnect then subscriptions will be renewed.
# mqttc.subscribe([(subTopicHam,0),(...)])
mqttc.subscribe([(subTopicRx,0),(subTopicStat,0),(subTopicNet,0)])
mqttc.on_connect = on_connect
# The callback for when the client diconnects from the broker (either politely, or impolitely).
def on_disconnect(client, userdata, flags, rc):
global MQTTconnected
MQTTconnected = False
# if rc != 0: msg = "Unexpected disconnection."
msg = "Disconnected with result code "+str(rc)
msg = stripLast(datetime.datetime.now(),".") + " " + msg
appendFile(logFileError, msg)
print(msg)
mqttc.on_disconnect = on_disconnect
# The callback for when a subscribed, topic with no sspecific callback message is received
def on_message(client, userdata, msg):
global soughtList
topic = msg.topic
payload = json.loads(msg.payload.decode("utf-8"))
# IF the topic is sought, extract the portion of the payload to return (speak) its value.
found = False
for resp in soughtList:
if topic == resp[DrespTopic]: # if the value of the response topic equals the received topic
found = True
lastResp = resp
result = getNestedDictKey(payload,resp[DrespKey])
# print(f"EXTRACTED: {resp[DrespKey]} from {topic} as {result}")
# print(f"SAY: {resp[Ddesc]} {result}")
mqttc.publish(subTopicTx, f"{resp[Ddesc]}, {result}") # issue the response to the radio transmitter
if found:
# not sure why this is necessary.
# .remove() seems to leave a NoneType if we delete the only element
if len(soughtList) == 1:
soughtList = []
else:
soughtList = soughtList.remove(lastResp)
# if found:
# print("Attended: ",end="")
# else:
# print("Unattended: ",end="")
# print(f"{str(topic)}: {str(payload)}")
mqttc.on_message = on_message
def callbackRx(client, userdata, msg):
payload = json.loads(msg.payload.decode("utf-8"))
time = payload.get("Time") # record always contains TYPE and DATA definitions
DTMF = payload.get("DTMF")
# print(f"{stripLast(datetime.datetime.now())} ", end="")
# print("DEBUG: ", DTMF)
found = False
for cmd in cmds:
if cmd[Ddtmf] == DTMF: # if the value of the DTMF key equals the received DTMF sequence
found = True
# Initial thoughts were that there's a better way to do this.
# Regardless if it's a set or a query, both consist of a command and a response:
# simply publish the command and subscribe to / act on the response.
# Conversely, it may actually have to get more complicated:
# if we want to set a value other than 1 or 0, we'll need to further parse the DTMF string.
if cmd[Ddtmf][0] == "*": # set
print(f"SET: {cmd[Dtopic]} {cmd[Ddata]} \"{cmd[Ddesc]}\"")
mqttc.publish(cmd[Dtopic], cmd[Ddata]) # issue the command to the remote device
mqttc.publish(subTopicTx, cmd[Ddesc]) # issue the response to the radio transmitter
if cmd[Ddtmf][0] == "#": # query
if cmd not in soughtList: soughtList.append(cmd) # add the response to the response search list
print(f"QUERY: {cmd[Dtopic]} {cmd[Ddata]} \"{cmd[Ddesc]}\"")
# print(f"EXPECTING: {cmd[DrespTopic]} {cmd[DrespKey]}")
# We should remove from the soughtList once the message has been received, as otherwise, the
# message will be repeated every time an MQTT status message containing the sought key is reecived,
# the message will be repeated.
# mqttc.publish(subTopicTx, "wilco") # respond to sender that the request is in progress
mqttc.publish(cmd[Dtopic], cmd[Ddata]) # provoke the response
# No need to UNSUBSCRIBE once the message has been received, as we'll only end up subscribing again.
# In any case the soughtList manages and finds responses we're seeking within the subscriptions.
if cmd[DrespTopic] not in subList: # command response has not yet been subscribed
subList.append(cmd[DrespTopic])
mqttc.subscribe(cmd[DrespTopic])
# print(f"New subscription: {cmd[DrespTopic]}")
if not found:
print(f"UNRECOGNISED: DTMF {DTMF}")
mqttc.publish(subTopicTx, DTMF + " not recognised") # issue the response to the radio transmitter
mqttc.message_callback_add(subTopicRx, callbackRx)
# PAHO (from v1.4) doesn't raise errors; it logs them. This function causes them to be printed.
def on_log(mqttc, obj, level, string):
print(string)
if logToScreen: mqttc.on_log = on_log
def appendFile(fname, txt):
f = open(fname, 'a') # append
f.write(f"{str(txt)}\n")
f.close()
def formatTime(s):
ss = int(s % 60)
m = int((s - ss) / 60)
mm = m % 60
hh = int((m - mm) / 60)
tStr = '{:02}'.format(hh) + ':' + '{:02}'.format(mm) + ':' + '{:02}'.format(ss)
return tStr
def plurals(n):
return compoundIf(n == 1,"","s")
########################################
# Main Program Starts Here
########################################
print("Starting...")
# test the array for duplicates at run start.
cmdlist = []
dupelist = []
dupecnt = 0
seqcnt = 0
for cmd in cmds:
if cmd[Ddtmf] in cmdlist:
dupecnt += 1
dupelist.append(cmd[Ddtmf])
else:
cmdlist.append(cmd[Ddtmf])
seqcnt += 1
del(cmdlist) # housekeeping: don't need this any more
print(f"{seqcnt} sequence{plurals(seqcnt)} {dupecnt} duplicate{plurals(dupecnt)}.")
if dupecnt > 0:
print(f"Duplicate{plurals(dupecnt)}: {dupelist}")
del(dupelist) # housekeeping
# MQTT broker connection data
mqttc.will_set(baseTopic + "/LWT", payload="Online")
mqttc.reconnect_delay_set(min_delay=2, max_delay=64)
mqttc.username_pw_set(MQTTuser, MQTTpass)
print("Running...")
print("Ctrl-C to exit")
# Main loop
while (True):
if not MQTTconnected:
try:
mqttc.connect(MQTTserver, 1883, 60) # THIS WILL FAIL if the script runs before the MQTT server starts (eg at a reboot)
except:
print("Connection fail.")
else:
mqttc.loop_start() # non-blocking call to manage network keep-alive, receiver, etc.
sleep(1)