Initial commit
This commit is contained in:
174
.gitignore
vendored
Normal file
174
.gitignore
vendored
Normal 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
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# thingy52-mqtt-bridge
|
||||
|
||||
MQTT bridge for BLE Nordic Instruments Thingy:52
|
||||
81
controller.py
Normal file
81
controller.py
Normal 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
362
device.py
Normal 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
554
existing.py
Normal 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
81
mqtt.py
Normal 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
43
utils.py
Normal 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
|
||||
Reference in New Issue
Block a user