#!/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()