Initial commit

This commit is contained in:
2025-05-03 23:59:00 +01:00
commit 2c0b308792
7 changed files with 1298 additions and 0 deletions

554
existing.py Normal file
View File

@@ -0,0 +1,554 @@
#!/usr/bin/env python3
"""
Reading values from thingy device and send them as mqtt messages.
Handles automatic reconnection if bluetooth connection is lost.
Source is derived from the bluepy library from Ian Harvey and Nordic Semiconductor
Bluepy repository: https://github.com/IanHarvey/bluepy/blob/master/bluepy/thingy52.py
Nordic Semiconductor Python: https://devzone.nordicsemi.com/b/blog/posts/nordic-thingy52-raspberry-pi-python-interface
Nordic Semiconductor NodeJS: https://github.com/NordicPlayground/Nordic-Thingy52-Thingyjs
dependencies installation on raspberry pi:
pip3 install bluepy
pip3 install paho-mqtt
to find the MAC address:
sudo hcitool lescan
Usage:
thingy52mqtt.py C2:9E:52:63:18:8A --no-mqtt --gas --temperature --humidity --pressure --battery --orientation --keypress --tap --sleep 5 -v -v -v -v -v
"""
import paho.mqtt.publish as publish
import paho.mqtt.client as mqtt_client
from bluepy import btle, thingy52
import time
import os
import argparse
import binascii
import logging
import signal, sys
import random
# constants for MQTT automatic reconnection
FIRST_RECONNECT_DELAY = 1
RECONNECT_RATE = 2
MAX_RECONNECT_COUNT = 12
MAX_RECONNECT_DELAY = 60
next_event_second = 0
args = None
thingy = None
# last values from received from notification:
temperature = None
pressure = None
humidity = None
eco2 = None
tvoc = None
color = None
button = None
tapDirection = None
tapCount = None
orientation = None
battery = None
def setupSignalHandler():
signal.signal(signal.SIGINT, _sigIntHandler)
signal.signal(signal.SIGTERM, _sigIntHandler)
logging.debug('Installed signal handlers')
def _sigIntHandler(signum, frame):
global thingy
logging.info('Received signal to exit')
if thingy:
thingy.disconnect()
disconnectMqtt()
exit(0)
def setupLogging():
'''Sets up logging'''
global args
if args.v > 5:
verbosityLevel = 5
else:
verbosityLevel = args.v
# https://docs.python.org/2/library/logging.html#logging-levels
verbosityLevel = (5 - verbosityLevel)*10
# print('loglevel %d v:%d' % (verbosityLevel, args.v))
format = '%(asctime)s %(levelname)-8s %(message)s'
if args.logfile is not None:
logging.basicConfig(filename=args.logfile, level=verbosityLevel, format=format)
else:
logging.basicConfig(level=verbosityLevel, format=format)
#logging.debug('debug') # 10
#logging.info('info') # 20
#logging.warn('warn') # 30
#logging.error('error') # 40
#logging.critical('critical') # 50
# logger = logging.getLogger(os.path.basename(__file__))
# logging.basicConfig(level=logging.DEBUG,
# format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
# datefmt='%yyyy%m%d-%H:%M:%S')
def connectMqtt():
def on_connect(client, userdata, flags, rc):
if rc == 0:
logging.info("Connected to MQTT broker")
else:
logging.error("Failed to connect to MQTT broker, return code %d\n", rc)
def on_disconnect(client, userdata, rc):
logging.info("Disconnected with result code: %s", rc)
reconnect_count, reconnect_delay = 0, FIRST_RECONNECT_DELAY
while reconnect_count < MAX_RECONNECT_COUNT:
logging.info("Reconnecting in %d seconds...", reconnect_delay)
time.sleep(reconnect_delay)
try:
client.reconnect()
logging.info("Reconnected successfully!")
return
except Exception as err:
logging.error("%s. Reconnect failed. Retrying...", err)
reconnect_delay *= RECONNECT_RATE
reconnect_delay = min(reconnect_delay, MAX_RECONNECT_DELAY)
reconnect_count += 1
logging.info("Reconnect failed after %s attempts. Exiting...", reconnect_count)
# Set Connecting Client ID
client = mqtt_client.Client(args.clientid)
client.username_pw_set(args.username, args.password)
client.tls_set(ca_certs="/home/service/mg36-ca-opnsense2.crt")
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.connect(args.hostname, args.port)
return client
def disconnectMqtt():
client.loop_stop()
mqttSend("available", "offline", '')
def sendMqttOnline():
mqttSend("available", "online", '')
def subscribeToNotifyAvailableWhenOnline(topic: str):
def on_message(client, userdata, msg):
logging.info(f"Received {msg.payload.decode()} from topic {msg.topic}")
if (msg.payload.decode() == "online"):
sendMqttOnline()
logging.info(f"Subscribing to topic {topic}")
client.subscribe(topic)
client.on_message = on_message
def mqttSendValues(notificationDelegate):
global temperature
global pressure
global humidity
global eco2
global tvoc
global color
global button
global tapDirection
global tapCount
global orientation
global battery
if args.temperature:
mqttSend('temperature', temperature, '°C')
temperature = None
if args.pressure:
mqttSend('pressure', pressure, 'hPa')
pressure = None
if args.humidity:
mqttSend('humidity', humidity, '%')
humidity = None
if args.gas:
mqttSend('eco2', eco2, 'ppm')
mqttSend('tvoc', tvoc, 'ppb')
eco2 = None
tvoc = None
if args.color:
mqttSend('color', color, '')
color = None
if args.tap:
mqttSend('tapdirection', tapDirection, '')
mqttSend('tapcount', tapCount, '')
tapDirection = None
tapCount = None
if args.orientation:
mqttSend('orientation', orientation, '')
orientation = None
if args.battery:
mqttSend('battery', battery, '%')
battery = None
def mqttSend(key, value, unit):
global args
if value is None:
logging.debug('no value given, do nothing for key %s' % key)
return
if isinstance(value, int):
logging.debug('Sending MQTT messages key %s value %d%s' % (key, value, unit))
elif isinstance(value, float) | isinstance(value, int):
logging.debug('Sending MQTT messages key %s value %.2f%s' % (key, value, unit))
elif isinstance(value, str):
logging.debug('Sending MQTT messages key %s value %s%s' % (key, value, unit))
else:
logging.debug('Sending MQTT messages key %s value %s%s' % (key, value, unit))
if args.mqttdisabled:
logging.debug('MQTT disabled, not sending message')
else:
try:
topic = args.topicprefix + key
payload = value
logging.debug('MQTT message topic %s, payload %s' % (topic, str(payload)))
client.publish(topic, payload)
except:
logging.error("Failed to publish message, details follow")
logging.error("hostname=%s topic=%s payload=%s" % (args.hostname, topic, payload))
logging.error(sys.exc_info()[0])
class MQTTDelegate(btle.DefaultDelegate):
def handleNotification(self, hnd, data):
global temperature
global pressure
global humidity
global eco2
global tvoc
global color
global button
global tapDirection
global tapCount
global orientation
#Debug print repr(data)
if (hnd == thingy52.e_temperature_handle):
teptep = binascii.b2a_hex(data)
value = self._str_to_int(teptep[:-2]) + int(teptep[-2:], 16) / 100.0
temperature = value
elif (hnd == thingy52.e_pressure_handle):
pressure_int, pressure_dec = self._extract_pressure_data(data)
value = pressure_int + pressure_dec / 100.0
pressure = value
elif (hnd == thingy52.e_humidity_handle):
teptep = binascii.b2a_hex(data)
value = self._str_to_int(teptep)
humidity = value
elif (hnd == thingy52.e_gas_handle):
eco2, tvoc = self._extract_gas_data(data)
eco2 = eco2
tvoc = tvoc
elif (hnd == thingy52.e_color_handle):
teptep = binascii.b2a_hex(data)
red, green, blue, clear = self._extract_color_data(data)
color = "0x%0.2X%0.2X%0.2X" %(red, green, blue)
# logging.debug('color %s red %d, green %d, blue %d, clear %d' % (color, red, green, blue, clear))
elif (hnd == thingy52.ui_button_handle):
teptep = binascii.b2a_hex(data)
value = int(teptep) # 1 = pressed, 0 = released
button = value
# send button press instantly without waiting for timeout:
mqttSend('button', button, '')
elif (hnd == thingy52.m_tap_handle):
direction, count = self._extract_tap_data(data)
match direction:
case 1:
tapDirection = "Bottom"
case 2:
tapDirection = "Left"
case 3:
tapDirection = "Top"
case 4:
tapDirection = "Right"
case 5:
tapDirection = "Front"
case 6:
tapDirection = "Back"
case _:
tapDirection = f"Unknown, {value}"
tapCount = count
elif (hnd == thingy52.m_orient_handle):
teptep = binascii.b2a_hex(data)
value = int(teptep)
# 1 = led top left
# 2 = led top right / left side up
# 3 = led bottom right/bottom up
# 0 = led bottom left/ right side up
match value:
case 0:
orientation = "LED Bottom Left"
case 1:
orientation = "LED Top Left"
case 2:
orientation = "LED Top Right"
case 3:
orientation = "LED Bottom Right"
case _:
orientation = f"Unknown, {value}"
# orientation = value
# elif (hnd == thingy52.m_heading_handle):
# teptep = binascii.b2a_hex(data)
# #value = int (teptep)
# logging.debug('Notification: Heading: {}'.format(teptep))
# #self.mqttSend('heading', value, 'degrees')
# elif (hnd == thingy52.m_gravity_handle):
# teptep = binascii.b2a_hex(data)
# logging.debug('Notification: Gravity: {}'.format(teptep))
# elif (hnd == thingy52.s_speaker_status_handle):
# teptep = binascii.b2a_hex(data)
# logging.debug('Notification: Speaker Status: {}'.format(teptep))
# elif (hnd == thingy52.s_microphone_handle):
# teptep = binascii.b2a_hex(data)
# logging.debug('Notification: Microphone: {}'.format(teptep))
else:
teptep = binascii.b2a_hex(data)
logging.debug('Notification: UNKOWN: hnd {}, data {}'.format(hnd, teptep))
def _str_to_int(self, s):
""" Transform hex str into int. """
i = int(s, 16)
if i >= 2**7:
i -= 2**8
return i
def _extract_pressure_data(self, data):
""" Extract pressure data from data string. """
teptep = binascii.b2a_hex(data)
pressure_int = 0
for i in range(0, 4):
pressure_int += (int(teptep[i*2:(i*2)+2], 16) << 8*i)
pressure_dec = int(teptep[-2:], 16)
return (pressure_int, pressure_dec)
def _extract_gas_data(self, data):
""" Extract gas data from data string. """
teptep = binascii.b2a_hex(data)
eco2 = int(teptep[:2], 16) + (int(teptep[2:4], 16) << 8)
tvoc = int(teptep[4:6], 16) + (int(teptep[6:8], 16) << 8)
return eco2, tvoc
def _extract_color_data(self, data):
""" Extract color data from data string. """
teptep = binascii.b2a_hex(data)
red = int(teptep[:2], 16)
green = int(teptep[2:4], 16)
blue = int(teptep[4:6], 16)
clear = int(teptep[6:8], 16)
return red, green, blue, clear
def _extract_tap_data(self, data):
""" Extract tap data from data string. """
teptep = binascii.b2a_hex(data)
direction = int(teptep[0:2])
count = int(teptep[2:4])
return (direction, count)
def parseArgs():
parser = argparse.ArgumentParser()
parser.add_argument('mac_address', action='store', help='MAC address of BLE peripheral')
parser.add_argument('-n', action='store', dest='count', default=0,
type=int, help="Number of times to loop data, if set to 0, loop endlessly")
parser.add_argument('-t', action='store', dest='timeout', type=float, default=2.0, help='time between polling')
parser.add_argument('--temperature', action="store_true",default=False)
parser.add_argument('--pressure', action="store_true",default=False)
parser.add_argument('--humidity', action="store_true",default=False)
parser.add_argument('--gas', action="store_true",default=False)
parser.add_argument('--color', action="store_true",default=False)
parser.add_argument('--keypress', action='store_true', default=False)
parser.add_argument('--battery', action='store_true', default=False)
parser.add_argument('--tap', action='store_true', default=False)
parser.add_argument('--orientation', action='store_true', default=False)
# mqtt arguments
parser.add_argument('--no-mqtt', dest='mqttdisabled', action='store_true', default=False)
parser.add_argument('--host', dest='hostname', default='localhost', help='MQTT hostname')
parser.add_argument('--port', dest='port', default=1883, type=int, help='MQTT port')
parser.add_argument('--topic-prefix', dest='topicprefix', default="/home/thingy/", help='MQTT topic prefix to post the values, prefix + key is used as topic')
parser.add_argument('--username', dest='username', default='mqttuser', help='MQTT username')
parser.add_argument('--password', dest='password', default='password', help='MQTT password')
parser.add_argument('--client-id', dest='clientid', default=f"python-mqtt-{random.randint(0, 1000)}", help='MQTT client ID')
parser.add_argument('--sleep', dest='sleep', default=60, type=int, help='Interval to publish values.')
parser.add_argument("--logfile", help="If specified, will log messages to the given file (default log to terminal)", default=None)
parser.add_argument("-v", help="Increase logging verbosity (can be used up to 5 times)", action="count", default=0)
args = parser.parse_args()
return args
def setNotifications(enable):
global thingy
global args
if args.temperature:
thingy.environment.set_temperature_notification(enable)
if args.pressure:
thingy.environment.set_pressure_notification(enable)
if args.humidity:
thingy.environment.set_humidity_notification(enable)
if args.gas:
thingy.environment.set_gas_notification(enable)
if args.color:
thingy.environment.set_color_notification(enable)
if args.tap:
thingy.motion.set_tap_notification(enable)
if args.orientation:
thingy.motion.set_orient_notification(enable)
def enableSensors():
global thingy
global args
# Enabling selected sensors
logging.debug('Enabling selected sensors...')
if args.temperature:
thingy.environment.enable()
thingy.environment.configure(temp_int=1000)
if args.pressure:
thingy.environment.enable()
thingy.environment.configure(press_int=1000)
if args.humidity:
thingy.environment.enable()
thingy.environment.configure(humid_int=1000)
if args.gas:
thingy.environment.enable()
thingy.environment.configure(gas_mode_int=1)
if args.color:
thingy.environment.enable()
thingy.environment.configure(color_int=1000)
thingy.environment.configure(color_sens_calib=[0,0,0])
# User Interface Service
if args.keypress:
thingy.ui.enable()
thingy.ui.set_btn_notification(True)
if args.battery:
thingy.battery.enable()
# Motion Service
if args.tap:
thingy.motion.enable()
thingy.motion.configure(motion_freq=200)
thingy.motion.set_tap_notification(True)
def connect(notificationDelegate):
global args
global thingy
connected = False
while not connected:
try:
logging.info('Try to connect to ' + args.mac_address)
thingy = thingy52.Thingy52(args.mac_address)
connected = True
logging.info('Connected...')
thingy.setDelegate(notificationDelegate)
sendMqttOnline()
except btle.BTLEException as ex:
connected = False
logging.debug('Could not connect, sleeping a while before retry')
time.sleep(args.sleep) # FIXME: use different sleep value??
def main():
global args
global thingy
global battery
global client
args = parseArgs()
setupLogging()
setupSignalHandler()
client = connectMqtt()
client.loop_start()
subscribeToNotifyAvailableWhenOnline("homeassistant/status")
notificationDelegate = MQTTDelegate()
connectAndReadValues = True
while connectAndReadValues:
connect(notificationDelegate)
#print("# Setting notification handler to default handler...")
#thingy.setDelegate(thingy52.MyDelegate())
try:
# Set LED so that we know we are connected
thingy.ui.enable()
#thingy.ui.set_led_mode_breathe(0x01, 1, 3000) # color 0x01 = RED, intensity, delay between breathes
thingy.ui.set_led_mode_constant(1,0,0)
logging.debug('LED set to breathe mode...')
enableSensors()
setNotifications(True)
counter = args.count
timeNextSend = time.time()
while connectAndReadValues:
# logging.debug('Loop start')
if args.battery:
value = thingy.battery.read()
battery = value
thingy.waitForNotifications(timeout = args.timeout)
counter -= 1
if counter == 0:
logging.debug('count reached, exiting...')
connectAndReadValues = False
if time.time() > timeNextSend:
mqttSendValues(notificationDelegate)
timeNextSend = time.time() + args.sleep
except btle.BTLEDisconnectError as e:
logging.debug('BTLEDisconnectError %s' % str(e))
logging.info('Disconnected...')
disconnectMqtt()
thingy = None
if thingy:
try:
thingy.disconnect()
#del thingy
finally:
disconnectMqtt()
if __name__ == "__main__":
main()