From 2c0b3087925712129d0f4e581756ed4d1fcd7c56 Mon Sep 17 00:00:00 2001 From: Matthew Grove Date: Sat, 3 May 2025 23:59:00 +0100 Subject: [PATCH] Initial commit --- .gitignore | 174 ++++++++++++++++ README.md | 3 + controller.py | 81 ++++++++ device.py | 362 +++++++++++++++++++++++++++++++++ existing.py | 554 ++++++++++++++++++++++++++++++++++++++++++++++++++ mqtt.py | 81 ++++++++ utils.py | 43 ++++ 7 files changed, 1298 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 controller.py create mode 100644 device.py create mode 100644 existing.py create mode 100644 mqtt.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a19790 --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc diff --git a/README.md b/README.md new file mode 100644 index 0000000..83adda4 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# thingy52-mqtt-bridge + +MQTT bridge for BLE Nordic Instruments Thingy:52 diff --git a/controller.py b/controller.py new file mode 100644 index 0000000..05a9d80 --- /dev/null +++ b/controller.py @@ -0,0 +1,81 @@ +from mqtt import MQTTConnection +from device import Device +import logging + +logging.basicConfig(level=logging.INFO) + +class Controller: + def __init__(self, + broker: str, + mac: str | None = None, + updateFrequency: int = 5, + port: int = 1883, + topic: str = "thingy52", + clientId: str = "thingy52", + username: str | None = None, + password: str | None = None, + caCertPath: str | None = None, + certPath: str | None = None, + keyPath: str | None = None, + mqttMinReconnectDelay: int = 1, + mqttMaxReconnectDelay: int = 60, + mqttReconnectAttempts: int = 20, + mqttReconnectDelayMultiplier: int = 2, + sensors: list[str] = [ + "battery", + "temperature", + "pressure", + "humidity", + "eco2", + "tvoc", + "button", + "taps", + "tapDirection", + "orient", + "stepCount", + "colour" + ], + temperatureOffset: int = 0 + ): + self.__mqtt = MQTTConnection( + broker, + port, + topic, + clientId, + username, + password, + caCertPath, + certPath, + keyPath, + mqttMinReconnectDelay, + mqttMaxReconnectDelay, + mqttReconnectAttempts, + mqttReconnectDelayMultiplier, + self.__stop + ) + self.__thingy = Device( + mac, + updateFrequency, + mqttMinReconnectDelay, + mqttMaxReconnectDelay, + mqttReconnectAttempts, + mqttReconnectDelayMultiplier, + self.__stop, + sensors, + temperatureOffset + ) + + try: + self.__mqtt.publish("available", "online") + self.__mqtt.subscribe("homeassistant/available", lambda msg : self.__mqtt.publish("available", "online") if (msg == "online") else None, False) + + for sensor in sensors: + self.__thingy.subscribe(sensor, lambda val, sensor=sensor : self.__mqtt.publish(sensor, val)) + + self.__thingy.start() + finally: + self.__stop() + + def __stop(self): + self.__thingy.disconnect() + self.__mqtt.disconnect() diff --git a/device.py b/device.py new file mode 100644 index 0000000..75679f1 --- /dev/null +++ b/device.py @@ -0,0 +1,362 @@ +from bluepy import btle, thingy52 +import logging +from collections.abc import Callable +from time import sleep, time +from utils import Utils +import binascii + +logger = logging.getLogger(__name__) + +class Device: + def __init__(self, + mac: str | None, + updateFrequency: int, + minReconnectDelay: int, + maxReconnectDelay: int, + reconnectAttempts: int, + reconnectDelayMultiplier: int, + disconnectCallback: Callable[[], None], + sensors: list[str], + temperatureOffset: int + ): + + self.__enabledSensors = sensors + self.__data = { + "battery": [None, []], + "temperature": [None, []], + "pressure": [None, []], + "humidity": [None, []], + "eco2": [None, []], + "tvoc": [None, []], + "button": [None, []], + "taps": [None, []], + "tapDirection": [None, []], + "orient": [None, []], + "stepCount": [None, []] + } + + self.__reconnect = Utils.getReconnector( + self.connect, + "Thingy", + minReconnectDelay, + maxReconnectDelay, + reconnectAttempts, + reconnectDelayMultiplier, + disconnectCallback + ) + self.__run = True + self.__temperatureOffset = temperatureOffset + + logger.info("Initialising device") + self.__updateFrequency = updateFrequency + self.__disconnectCallback = disconnectCallback + self.__delegate = NotificationDelegate(self.__setData, self.__notifySubscribers) + + while True: + try: + self.__mac = mac or Device.findMac() + self.connect() + break + except btle.BTLEDisconnectError or BTLEInternalError: + logging.warn("Unable to connect to Thingy, trying again") + + logger.info("Finished device setup") + + def start(self): + logging.debug("Starting reporting loop") + nextReport = time() + 2 + batteryReportInterval = max(60, self.__updateFrequency) + nextBatteryReport = nextReport + connected = True + while self.__run: + try: + if (not connected): + self.__reconnect() + nextReport = time() + 2 + connected = True + logging.debug("New reporting loop iteration") + + if ("battery" in self.__enabledSensors and time() >= nextBatteryReport): + self.__setData("battery", self.__dev.battery.read()) + nextBatteryReport = time() + max(60, self.__updateFrequency) + self.__dev.waitForNotifications(timeout=self.__updateFrequency) + + if (time() >= nextReport): + logging.debug("Reporting sensor data") + for sensor in self.__data.keys(): + if (sensor in self.__enabledSensors): self.__notifySubscribers(sensor) + nextReport = time() + self.__updateFrequency + except btle.BTLEDisconnectError or BTLEInternalError: + logging.warning("Thingy disconnected, reconnecting") + connected = False + + def connect(self): + self.__dev = thingy52.Thingy52(self.__mac) + logging.info("Connected to device") + self.__dev.setDelegate(self.__delegate) + logging.debug("Configured notification handler") + + self.__dev.ui.enable() + self.__dev.ui.set_led_mode_constant(1,0,1) + + self.__enableSensors() + self.__enableNotifications() + + + def disconnect(self): + self.__run = False + self.__dev.disconnect() + + def subscribe(self, sensor: str, callback: Callable[[int], None]): + if (sensor in self.__data.keys() and sensor in self.__enabledSensors): + self.__data[sensor][1].append(callback) + logging.debug(f"Current subscriptions for {sensor}: {repr(self.__data[sensor][1])}") + + def unsubscribe(self, sensor: str, callback: Callable[[int], None]): + if (sensor in self.__data.keys() and sensor in self.__enabledSensors): + self.__data[sensor][1].remove(callback) + + def __notifySubscribers(self, sensor: str): + if (sensor not in self.__data.keys()): return + data = self.__data[sensor][0] + logging.debug(f"Current {sensor}: {data}") + if (data != None): + for callback in self.__data[sensor][1]: + callback(data) + self.__data[sensor][0] = None + + def __setData(self, sensor: str, val: str): + if (sensor in self.__data.keys()): + if (sensor == "temperature"): + self.__data[sensor][0] = float(val) + self.__temperatureOffset + else: + self.__data[sensor][0] = val + + def __enableSensors(self): + logging.info("Enabling sensors") + # environment + if ("temperature" in self.__enabledSensors): + self.__dev.environment.enable() + self.__dev.environment.configure(temp_int=self.__updateFrequency * 1000) + logging.debug("Enabled temperature sensor") + if ("pressure" in self.__enabledSensors): + self.__dev.environment.enable() + self.__dev.environment.configure(press_int=self.__updateFrequency * 1000) + logging.debug("Enabled pressure sensor") + if ("humidity" in self.__enabledSensors): + self.__dev.environment.enable() + self.__dev.environment.configure(humid_int=self.__updateFrequency * 1000) + logging.debug("Enabled humidity sensor") + if ("eco2" in self.__enabledSensors or "tvoc" in self.__enabledSensors): + self.__dev.environment.enable() + self.__dev.environment.configure(gas_mode_int=1) + logging.debug("Enabled gas sensors") + if ("colour" in self.__enabledSensors): + self.__dev.environment.enable() + self.__dev.environment.configure(color_int=self.__updateFrequency * 1000) + self.__dev.environment.configure(color_sens_calib=[0,0,0]) + logging.debug("Enabled colour sensor") + # UI + if ("button" in self.__enabledSensors): + self.__dev.ui.enable() + logging.debug("Enabled button sensor") + if ("battery" in self.__enabledSensors): + self.__dev.battery.enable() + logging.debug("Enabled battery sensor") + # motion + if ("taps" in self.__enabledSensors): + self.__dev.motion.enable() + self.__dev.motion.configure(motion_freq=200) + logging.debug("Enabled taps sensor") + if ("orient" in self.__enabledSensors): + self.__dev.motion.enable() + logging.debug("Enabled orient sensor") + if ("stepCount" in self.__enabledSensors): + self.__dev.motion.enable() + self.__dev.motion.configure(step_int=self.__updateFrequency * 1000) + logging.debug("Enabled stepCount sensor") + logging.info("Enabled sensors") + + def __enableNotifications(self): + logging.info("Enabling notifications") + # environment + if ("temperature" in self.__enabledSensors): + self.__dev.environment.set_temperature_notification(True) + logging.debug("Enabled temperature sensor") + if ("pressure" in self.__enabledSensors): + self.__dev.environment.set_pressure_notification(True) + logging.debug("Enabled pressure sensor") + if ("humidity" in self.__enabledSensors): + self.__dev.environment.set_humidity_notification(True) + logging.debug("Enabled humidity sensor") + if ("eco2" in self.__enabledSensors or "tvoc" in self.__enabledSensors): + self.__dev.environment.set_gas_notification(True) + logging.debug("Enabled gas sensors") + if ("colour" in self.__enabledSensors): + self.__dev.environment.set_color_notification(True) + logging.debug("Enabled colour sensor") + # UI + if ("button" in self.__enabledSensors): + self.__dev.ui.set_btn_notification(True) + logging.debug("Enabled button sensor") + # motion + if ("taps" in self.__enabledSensors): + self.__dev.motion.set_tap_notification(True) + logging.debug("Enabled taps sensor") + if ("orient" in self.__enabledSensors): + self.__dev.motion.set_orient_notification(True) + logging.debug("Enabled orient sensor") + if ("stepCount" in self.__enabledSensors): + self.__dev.motion.set_stepcnt_notification(True) + logging.debug("Enabled stepCount sensor") + logging.info("Enabled notifications") + + @staticmethod + def findMac(): + while True: + logger.info("Scanning for BLE devices...") + for device in btle.Scanner().scan(timeout=5): + logger.info(f"Found device with MAC {device.addr} with RSSI ${device.rssi}. Checking if it's a Thingy...") + for (_, _, value) in device.getScanData(): + if (value == "Thingy"): + logger.info("... this device is a Thingy") + return device.addr + +class NotificationDelegate(btle.DefaultDelegate): + + def __init__(self, setData: Callable[[str, str], None], notifySubscribers: Callable[[str], None]): + self.__setData = setData + self.__notifySubscribers = notifySubscribers + + def handleNotification(self, hnd, data): + + logging.debug(f"Handling notification: {repr(hnd)}") + + if (hnd == thingy52.e_temperature_handle): + teptep = binascii.b2a_hex(data) + self.__setData("temperature", Utils.hexStrToInt(teptep[:-2]) + int(teptep[-2:], 16) / 100.0) + + elif (hnd == thingy52.e_pressure_handle): + pressure_int, pressure_dec = self.__extractPressureData(data) + self.__setData("pressure", pressure_int + pressure_dec / 100.0) + + elif (hnd == thingy52.e_humidity_handle): + teptep = binascii.b2a_hex(data) + self.__setData("humidity", Utils.hexStrToInt(teptep)) + + elif (hnd == thingy52.e_gas_handle): + eco2, tvoc = self.__extractGasData(data) + logging.debug(f"Handling gas sensor update: {eco2}, {tvoc}") + self.__setData("eco2", eco2) + self.__setData("tvoc", tvoc) + + elif (hnd == thingy52.e_color_handle): + teptep = binascii.b2a_hex(data) + red, green, blue, clear = self.__extractColourData(data) + self.__setData("colour", "0x%0.2X%0.2X%0.2X" %(red, green, blue)) + + elif (hnd == thingy52.ui_button_handle): + teptep = binascii.b2a_hex(data) + self.__setData("button", "on" if Utils.hexStrToInt(teptep) == 1 else "off") # 1 = pressed, 0 = released + self.__notifySubscribers("button") + + elif (hnd == thingy52.m_tap_handle): + direction, count = self.__extractTapData(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}" + self.__setData("tapDirection", tapDirection) + self.__setData("taps", count) + + + elif (hnd == thingy52.m_orient_handle): + teptep = binascii.b2a_hex(data) + value = Utils.hexStrToInt(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}" + self.__setData("orient", orientation) + + elif (hnd == thingy52.m_stepcnt_handle): + logging.info("Handling step count update") + teptep = binascii.b2a_hex(data) + self.__setData("stepCount", Utils.hexStrToInt(teptep)) + + elif (hnd == thingy52.m_rotation_handle): + teptep = binascii.b2a_hex(data) + self.__setData("rotation", Utils.hexStrToInt(teptep)) + + elif (hnd == thingy52.m_heading_handle): + teptep = binascii.b2a_hex(data) + self.__setData("heading", Utils.hexStrToInt(teptep)) + + elif (hnd == thingy52.m_gravity_handle): + teptep = binascii.b2a_hex(data) + self.__setData("gravity", Utils.hexStrToInt(teptep)) + + elif (hnd == thingy52.s_speaker_status_handle): + teptep = binascii.b2a_hex(data) + self.__setData("speaker", Utils.hexStrToInt(teptep)) + + elif (hnd == thingy52.s_microphone_handle): + teptep = binascii.b2a_hex(data) + + else: + logging.warn(f"Unknown notification: {hnd}, {data}") + + + def __extractPressureData(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 __extractGasData(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 __extractColourData(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 __extractTapData(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) \ No newline at end of file diff --git a/existing.py b/existing.py new file mode 100644 index 0000000..98e920c --- /dev/null +++ b/existing.py @@ -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() diff --git a/mqtt.py b/mqtt.py new file mode 100644 index 0000000..add14d6 --- /dev/null +++ b/mqtt.py @@ -0,0 +1,81 @@ +from paho.mqtt import client as mqtt_client +import logging +from sys import exit +from time import sleep +from collections.abc import Callable +from utils import Utils + +logger = logging.getLogger(__name__) + +class MQTTConnection: + def __init__(self, + broker: str, + port: int, + baseTopic: str, + clientId: str, + username: str | None, + password: str | None, + caCertPath: str | None, + certPath: str | None, + keyPath: str | None, + minReconnectDelay: int, + maxReconnectDelay: int, + reconnectAttempts: int, + reconnectDelayMultiplier: int, + disconnectCallback: Callable[[], None] + ): + self.__broker = broker + self.__port = port + self.__baseTopic = baseTopic + self.__clientId = clientId + self.__client = mqtt_client.Client(self.__clientId) + + if (username and password): + self.__client.username_pw_set(username, password) + self.__client.tls_set(ca_certs=caCertPath, certfile=certPath, keyfile=keyPath) + self.__client.on_connect = self.__onConnect + + self.__disconnectCallback = disconnectCallback + + self.__client.on_disconnect = Utils.getReconnector( + self.__client.reconnect, + "MQTT broker", + minReconnectDelay, + maxReconnectDelay, + reconnectAttempts, + reconnectDelayMultiplier, + self.__disconnectCallback + ) + + self.__connect() + + self.__client.loop_start() + + def __onConnect(self, client, userdata, flags, rc): + if (rc == 0): + logger.info("Connected to MQTT broker") + else: + logger.error("Unable to connect to MQTT broker, exiting") + self.__disconnectCallback() + exit() + + def publish(self, subTopic: str, message: str) -> int: + topic = self.__baseTopic + "/" + subTopic + logging.debug(f"Sending MQTT payload to topic {topic}: {message}") + return self.__client.publish(topic, message)[0] + + def subscribe(self, subTopic: str, callback: Callable[[str], None], prefixBaseTopic: bool = True) -> None: + def onMessage(client, userdata, msg): + callback(msg.payload.decode()) + + topic = self.__baseTopic + "/" + subTopic if prefixBaseTopic else subTopic + self.__client.subscribe(topic) + self.__client.on_message = onMessage + + def disconnect(self): + self.__client.loop_stop() + self.publish("available", "offline") + + def __connect(self): + self.__client.connect(self.__broker, self.__port) + diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..78f1697 --- /dev/null +++ b/utils.py @@ -0,0 +1,43 @@ +from time import sleep +import logging +from collections.abc import Callable + +logger = logging.getLogger(__name__) + +class Utils: + + @staticmethod + def getReconnector( + reconnect: Callable[[], None], + description: str, + minReconnectDelay: int, + maxReconnectDelay: int, + reconnectAttempts: int, + reconnectDelayMultiplier: int, + disconnectCallback: Callable[[], None] + ): + def fn(): + logger.warn(f"Disconnected from {description}, trying to reconnect") + reconnectCount = 0 + reconnectDelay = minReconnectDelay + while (reconnectAttempts < 0 or reconnectCount < reconnectAttempts): + sleep(reconnectDelay) + + try: + reconnect() + logging.info(f"Reconnected to {description}") + return + except: + reconnectDelay = min(minReconnectDelay, reconnectDelay * reconnectDelayMultiplier) + reconnectCount += 1 + logger.error(f"Unable to reconnect to {description} after {reconnectCount} attempts, exiting") + disconnectCallback() + return fn + + @staticmethod + def hexStrToInt(s): + # hex string to int + i = int(s, 16) + if i >= 2**7: + i -= 2**8 + return i \ No newline at end of file