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

174
.gitignore vendored Normal file
View File

@@ -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

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# thingy52-mqtt-bridge
MQTT bridge for BLE Nordic Instruments Thingy:52

81
controller.py Normal file
View File

@@ -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()

362
device.py Normal file
View File

@@ -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)

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()

81
mqtt.py Normal file
View File

@@ -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)

43
utils.py Normal file
View File

@@ -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