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