├── inkbird ├── version.py ├── mqtt.py ├── const.py ├── client.py └── hass.py ├── requirements.txt ├── docker-compose.yaml ├── Dockerfile ├── README.md ├── main.py ├── azure-pipelines.yml ├── .gitignore └── .dockerignore /inkbird/version.py: -------------------------------------------------------------------------------- 1 | version = "1.2.0" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bluepy==1.3.0 2 | paho-mqtt==1.5.0 3 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | inkbird: 5 | image: jshridha/inkbird:1.1.1 6 | network_mode: host 7 | cap_add: 8 | - SYS_ADMIN 9 | - NET_ADMIN 10 | environment: 11 | - INKBIRD_MQTT_HOST=hass.local 12 | - INKBIRD_MQTT_USERNAME=inkbird 13 | - INKBIRD_MQTT_PASSWORD=mqttpassword 14 | - INKBIRD_ADDRESS=90:70:65:F7:8D:AD 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM arm32v6/python:3.7-alpine as builder 2 | 3 | WORKDIR /wheels 4 | COPY requirements.txt ./ 5 | RUN apk add --update-cache && \ 6 | apk add --update alpine-sdk glib-dev 7 | RUN pip wheel -r requirements.txt 8 | 9 | FROM arm32v6/python:3.7-alpine 10 | COPY --from=builder /wheels /wheels 11 | 12 | WORKDIR /usr/src/app 13 | 14 | RUN apk add --update-cache \ 15 | glib && \ 16 | pip install --no-cache-dir -r /wheels/requirements.txt -f /wheels && \ 17 | rm -rf /var/cache/apk/* 18 | 19 | COPY . . 20 | 21 | CMD ["python", "main.py"] 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inkbird to Homeassistant 2 | ## The purpose of this project is to get popular multi-probe inkbird thermometers into homeassistant for automations, temperature logging, changing lights based on BBQ status, and whatever else you can think of. 3 | 4 | ## Requirements 5 | 6 | This is designed to run on a raspberry pi, but it should work fine on any linux system with a BLE adapter. 7 | 8 | 9 | ## Quick Start 10 | The easiest way to run this probject is to run the image from docker hub using docker-compose 11 | 12 | wget https://raw.githubusercontent.com/jshridha/inkbird/master/docker-compose.yaml 13 | 14 | You will need to modify the environmental variables 15 | | Variable | Required (Y/N) | Description | 16 | |----------|----------------|-------------| 17 | | `INKBIRD_MQTT_HOST` | Y | MQTT server that home assistant uses 18 | | `INKBIRD_ADDRESS` | Y | The bluetooth address for the inkbird 19 | | `INKBIRD_MQTT_USERNAME` | N | MQTT username 20 | | `INKBIRD_MQTT_PASSWORD` | N | MQTT password 21 | | `INKBIRD_TEMP_UNITS` | N | Set to `F` for farenheit and `C` for celsius (defaults to `F`) 22 | 23 | Then just run `docker-compose up -d` 24 | -------------------------------------------------------------------------------- /inkbird/mqtt.py: -------------------------------------------------------------------------------- 1 | import paho.mqtt.client as mqtt 2 | import os 3 | 4 | 5 | class MqttController: 6 | def __init__(self): 7 | self.setup() 8 | 9 | def setup(self): 10 | host = os.environ.get("INKBIRD_MQTT_HOST") 11 | port = int(os.environ.get("INKBIRD_MQTT_PORT", 1883)) 12 | username = os.environ.get("INKBIRD_MQTT_USERNAME", "") 13 | password = os.environ.get("INKBIRD_MQTT_PASSWORD", "") 14 | 15 | client = mqtt.Client(client_id="inkbird") 16 | client.will_set("inkbird/status", payload="offline", retain=True, qos=0) 17 | 18 | def on_connect(client, userdata, flags, rc): 19 | client.publish("inkbird/status", "online", retain=True, qos=0) 20 | 21 | client.on_connect = on_connect 22 | 23 | client.username_pw_set(username, password) 24 | client.connect(host=host, port=port) 25 | client.loop_start() 26 | 27 | self.client = client 28 | 29 | def publish(self, topic, message, retainMessage=False): 30 | if not self.client.is_connected(): 31 | self.setup() 32 | self.client.publish(topic, message, retain=retainMessage) 33 | 34 | 35 | client = MqttController() 36 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from inkbird.client import InkBirdClient 2 | import bluepy 3 | import time 4 | import os 5 | 6 | import logging 7 | 8 | logger = logging.getLogger("inkbird") 9 | logger.setLevel(logging.INFO) 10 | 11 | logging.basicConfig( 12 | level=logging.INFO, format="%(asctime)s %(name)-15s %(levelname)-8s %(message)s" 13 | ) 14 | 15 | MAX_BACKOFF = 60 16 | BACKOFF = 1 17 | INITIAL_BACKOFF = 1 18 | 19 | if __name__ == "__main__": 20 | address = os.environ.get("INKBIRD_ADDRESS") 21 | client = InkBirdClient(address=address) 22 | 23 | while True: 24 | try: 25 | logger.info(f"Connecting to {address}") 26 | client.connect() 27 | client.login() 28 | client.enable_data() 29 | client.enable_battery() 30 | 31 | logger.debug("Starting Loop") 32 | BACKOFF = INITIAL_BACKOFF 33 | while True: 34 | try: 35 | if client.client.waitForNotifications(1.0): 36 | continue 37 | except bluepy.btle.BTLEInternalError: 38 | pass 39 | except bluepy.btle.BTLEDisconnectError: 40 | time.sleep(min(BACKOFF, MAX_BACKOFF)) 41 | BACKOFF *= 2 42 | logger.info(f"Reconnecting to {address}") 43 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | tags: 3 | include: 4 | - '*' 5 | branches: 6 | include: 7 | - '*' 8 | 9 | pool: 10 | vmImage: 'ubuntu-latest' 11 | 12 | steps: 13 | 14 | # Prepare 15 | # Docker 16 | - task: DockerInstaller@0 17 | displayName: Docker install 18 | condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) 19 | inputs: 20 | dockerVersion: 19.03.5 21 | releaseType: stable 22 | - task: Docker@2 23 | displayName: Docker registry login 24 | condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) 25 | inputs: 26 | command: login 27 | containerRegistry: docker_registry 28 | - bash: | 29 | sudo wget -O /usr/local/bin/buildx https://github.com/docker/buildx/releases/download/v0.3.1/buildx-v0.3.1.linux-amd64 30 | sudo chmod a+x /usr/local/bin/buildx 31 | docker run --rm --privileged hypriot/qemu-register:v2.7.0 32 | buildx create --use 33 | buildx ls 34 | displayName: 'Docker setup' 35 | condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) 36 | - bash: | 37 | buildx build \ 38 | --platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/386 \ 39 | --push \ 40 | -t jshridha/inkbird:latest-dev \ 41 | . 42 | displayName: 'Docker build dev' 43 | condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/dev'), ne(variables['Build.Reason'], 'PullRequest')) 44 | - bash: | 45 | buildx build \ 46 | --platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/386 \ 47 | --push \ 48 | -t jshridha/inkbird:latest \ 49 | . 50 | displayName: 'Docker build dev' 51 | condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master'), ne(variables['Build.Reason'], 'PullRequest')) 52 | - bash: | 53 | TAG="$(git describe --tags)" 54 | buildx build \ 55 | --platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/386 \ 56 | --push \ 57 | -t jshridha/inkbird:latest -t "jshridha/inkbird:$TAG" \ 58 | . 59 | displayName: 'Docker build release' 60 | condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'), ne(variables['Build.Reason'], 'PullRequest')) 61 | -------------------------------------------------------------------------------- /inkbird/const.py: -------------------------------------------------------------------------------- 1 | SETTINGS_RESULT_UUID = 0xFFF1 2 | ACCOUNT_AND_VERIFY_UUID = 0xFFF2 3 | HISTORY_DATA_UUID = 0xFFF3 4 | REALTIME_DATA_UUID = 0xFFF4 5 | SETTINGS_DATA_UUID = 0xFFF5 6 | 7 | CREDENTIALS_MESSAGE = bytes( 8 | [ 9 | 0x21, 10 | 0x07, 11 | 0x06, 12 | 0x05, 13 | 0x04, 14 | 0x03, 15 | 0x02, 16 | 0x01, 17 | 0xB8, 18 | 0x22, 19 | 0x00, 20 | 0x00, 21 | 0x00, 22 | 0x00, 23 | 0x00, 24 | ] 25 | ) 26 | REALTIME_DATA_ENABLE_MESSAGE = bytes([0x0B, 0x01, 0x00, 0x00, 0x00, 0x00]) 27 | UNITS_F_MESSAGE = bytes([0x02, 0x01, 0x00, 0x00, 0x00, 0x00]) 28 | UNITS_C_MESSAGE = bytes([0x02, 0x00, 0x00, 0x00, 0x00, 0x00]) 29 | REQ_BATTERY_MESSAGE = bytes([0x08, 0x24, 0x00, 0x00, 0x00, 0x00]) 30 | 31 | BATTERY_CORRECTION = [ 32 | 5580, 33 | 5595, 34 | 5609, 35 | 5624, 36 | 5639, 37 | 5644, 38 | 5649, 39 | 5654, 40 | 5661, 41 | 5668, 42 | 5676, 43 | 5683, 44 | 5698, 45 | 5712, 46 | 5727, 47 | 5733, 48 | 5739, 49 | 5744, 50 | 5750, 51 | 5756, 52 | 5759, 53 | 5762, 54 | 5765, 55 | 5768, 56 | 5771, 57 | 5774, 58 | 5777, 59 | 5780, 60 | 5783, 61 | 5786, 62 | 5789, 63 | 5792, 64 | 5795, 65 | 5798, 66 | 5801, 67 | 5807, 68 | 5813, 69 | 5818, 70 | 5824, 71 | 5830, 72 | 5830, 73 | 5830, 74 | 5835, 75 | 5840, 76 | 5845, 77 | 5851, 78 | 5857, 79 | 5864, 80 | 5870, 81 | 5876, 82 | 5882, 83 | 5888, 84 | 5894, 85 | 5900, 86 | 5906, 87 | 5915, 88 | 5924, 89 | 5934, 90 | 5943, 91 | 5952, 92 | 5961, 93 | 5970, 94 | 5980, 95 | 5989, 96 | 5998, 97 | 6007, 98 | 6016, 99 | 6026, 100 | 6035, 101 | 6044, 102 | 6052, 103 | 6062, 104 | 6072, 105 | 6081, 106 | 6090, 107 | 6103, 108 | 6115, 109 | 6128, 110 | 6140, 111 | 6153, 112 | 6172, 113 | 6191, 114 | 6211, 115 | 6230, 116 | 6249, 117 | 6265, 118 | 6280, 119 | 6296, 120 | 6312, 121 | 6328, 122 | 6344, 123 | 6360, 124 | 6370, 125 | 6381, 126 | 6391, 127 | 6407, 128 | 6423, 129 | 6431, 130 | 6439, 131 | 6455, 132 | ] 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | docker-compose.yaml 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | -------------------------------------------------------------------------------- /inkbird/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import array 4 | from collections import deque 5 | import struct 6 | import logging 7 | import threading 8 | 9 | 10 | from .hass import Probe, Battery 11 | 12 | from bluepy import btle 13 | 14 | from . import const 15 | from collections import defaultdict 16 | 17 | logger = logging.getLogger("inkbird") 18 | 19 | 20 | class Timer(threading.Timer): 21 | def run(self): 22 | while not self.finished.is_set(): 23 | self.finished.wait(self.interval) 24 | self.function(*self.args, **self.kwargs) 25 | 26 | self.finished.set() 27 | 28 | 29 | class key_dependent_dict(defaultdict): 30 | def __init__(self, f_of_x): 31 | super().__init__(None) # base class doesn't get a factory 32 | self.f_of_x = f_of_x # save f(x) 33 | 34 | def __missing__(self, key): # called when a default needed 35 | ret = self.f_of_x(key) # calculate default value 36 | self[key] = ret # and install it in the dict 37 | return ret 38 | 39 | 40 | class Delegate(btle.DefaultDelegate): 41 | def __init__(self, address): 42 | super().__init__() 43 | self.address = address 44 | self.probes = key_dependent_dict(lambda x: Probe(self.address, x, self.battery.value)) 45 | self._battery = None 46 | 47 | def handleNotification(self, cHandle, data): 48 | logger.debug(f"New Data: {(cHandle, data)}") 49 | if cHandle == 48: 50 | self.handleTemperature(data) 51 | if cHandle == 37: 52 | self.handleBattery(data) 53 | 54 | def handleTemperature(self, data): 55 | temp = array.array("H") 56 | temp.fromstring(data) 57 | for probe, t in enumerate(temp): 58 | self.probes[probe + 1].temperature = t 59 | 60 | def __batteryPercentage(self, current, max): 61 | factor = max / 6550.0 62 | current /= factor 63 | if current > const.BATTERY_CORRECTION[-1]: 64 | return 100 65 | if current <= const.BATTERY_CORRECTION[0]: 66 | return 0 67 | for idx, voltage in enumerate(const.BATTERY_CORRECTION, start=0): 68 | if (current > voltage) and (current <= (const.BATTERY_CORRECTION[idx + 1])): 69 | return idx + 1 70 | return 100 71 | 72 | def handleBattery(self, data): 73 | if data[0] != 36: 74 | return 75 | battery, maxBattery = struct.unpack("= 0 and temperature < 10000 else None 125 | else: 126 | temperature = temperature / 10 * 9 / 5 + 32 if temperature >= 0 and temperature < 10000 else None 127 | if self._temperature == temperature: 128 | return 129 | self._temperature = temperature 130 | self.update() 131 | 132 | 133 | class Battery(Sensor): 134 | def __init__(self, mac): 135 | self._value = None 136 | super().__init__(mac) 137 | 138 | def build_message(self): 139 | return {"value": self.value} 140 | 141 | def discovery_topic(self): 142 | return f"{super().discovery_topic()}/battery/config" 143 | 144 | def publish_topic(self): 145 | return f"{super().publish_topic()}/battery" 146 | 147 | def name(self): 148 | return f"{super().name()} Battery" 149 | 150 | def unique_id(self): 151 | return f"{super().unique_id()}_battery" 152 | 153 | def value_template(self): 154 | return "{{ value_json.value }}" 155 | 156 | def set_logger(self): 157 | self.logger = logger.getChild("battery") 158 | 159 | def device_class(self): 160 | return "battery" 161 | 162 | def units(self): 163 | return "%" 164 | 165 | @property 166 | def value(self): 167 | return self._value 168 | 169 | @value.setter 170 | def value(self, value): 171 | if self._value == value: 172 | return 173 | self._value = value 174 | self.update() 175 | --------------------------------------------------------------------------------