Compare commits

..

12 Commits

Author SHA1 Message Date
1ef9149ea2 Update README.md 2024-11-10 12:09:41 +00:00
0694941c40 Update README.md 2024-11-10 12:08:59 +00:00
079f901544 Upload New File 2024-11-10 12:07:22 +00:00
a3ece51e90 Upload New File 2024-11-10 12:07:08 +00:00
e78d7f863a Merge branch 'mqtt' into 'develop'
add MQTT support plus homeassistant MQTT discovery

See merge request DasMoorhuhn/atc_mithermometer_gateway!5
2024-11-10 02:14:13 +00:00
84cae7fc26 add MQTT support plus homeassistant MQTT discovery 2024-11-10 03:13:36 +01:00
6d3755e465 try to fix integration 2024-09-12 15:19:48 +02:00
ed44da8427 ha integration 2024-09-12 03:38:08 +02:00
932168125c add ha integration and added entity state api endpoint 2024-09-12 01:04:25 +02:00
249b111e67 fix api and stuff 2024-09-11 15:58:30 +02:00
387c41db05 improved code 2024-09-01 04:16:24 +02:00
2eaa1fb847 added release build 2024-08-29 02:16:06 +02:00
22 changed files with 501 additions and 72 deletions

BIN
.media/ha_integration.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -3,6 +3,8 @@ FROM python:3.12-alpine3.20 AS pip_build_stage
COPY ./python/requierements.txt /
RUN pip3.12 install setuptools
RUN apk add \
make \
git \

View File

@ -22,22 +22,23 @@ Python gateway for the [custom firmware](https://github.com/atc1441/ATC_MiThermo
- Make in runnable in a docker container (because only cool people are using docker)
- Make docker image smaller. I mean shiiit 1GB D: should be possible to be under 500MB (It's now around 100MB)
- Implement a loop for fetching the data every X seconds
- Storing temperature, humidity and battery state as json in a text file
- Can run on Raspberry Pi (3, 4, zero w) or any other Linux driven hardware which has BLE and WiFi support
**TODOs:**
- [WIP] Can run on Raspberry Pi (3, 4, zero w) or any other Linux driven hardware which has BLE and WiFi support
- [WIP] Storing temperature, humidity and battery state as json in a text file
- [WIP] MQTT publishing with discovery for homeassistant
- [TODO] Make a microPython version for using the raspberry pico w or any other microcontroller with BLE and WiFi support
- [TODO] Collect data from multiple devices/gateways
- [TODO] Command line tool for managing the devices
- [TODO] Analyzing tool for making statistics
- [TODO] HomeAssistant integration
- [TODO] MQTT publishing
- [TODO] Maybe... a webinterface. But I suck at web stuff, so I don't know.
- [TODO] Implement other BLE Sensors
**Current State**
![](.media/demo.gif)
![](.media/home_assistant_device.jpg)
![](.media/home_assistant_android_widget.jpg)
## Getting started

View File

@ -5,6 +5,7 @@ HELP="USAGE: sh build_docker.sh \n
[ -t | --tag ] Select a tag for building. Default: latest \n
[ -i | --image ] Select image tag for building. Default: dasmoorhuhn/atc-mithermometer-gateway \n
[ -p | --platforms ] Select the platforms, for which the image should build. Default: linux/amd64,linux/arm64,linux/arm \n
[ -r | --release ] Build a release. Provide the Tag. \n
[ -h | --help ] Get this dialog"
docker buildx version
@ -24,6 +25,29 @@ build_docker() {
docker buildx build --tag $IMAGE:$TAG --platform=$PLATFORMS --push .
}
build_release() {
branch=$(git symbolic-ref --short HEAD)
git stash
git fetch --prune --prune-tags -f
git checkout $TAG
echo -------------------------------------
git branch
echo -------------------------------------
git status
echo -------------------------------------
echo "!!PLEASE CHECK IF THIS IS RIGHT!!"
sleep 15
clear
echo Build Tag $TAG
build_docker
TAG=latest
clear
echo Build Tag $TAG
build_docker
git checkout $branch
git stash pop
}
while [ "$1" != "" ]; do
case $1 in
-t | --tag )
@ -31,6 +55,12 @@ while [ "$1" != "" ]; do
TAG=$1
shift
;;
-r | --release )
shift
TAG=$1
RELEASE=true
shift
;;
-i | --image )
shift
IMAGE=$1
@ -52,4 +82,8 @@ while [ "$1" != "" ]; do
esac
done
if [ "$RELEASE" = true ]; then
build_release
else
build_docker
fi

View File

@ -4,7 +4,9 @@ NAME=
INTERACTIVE=true
BUILD=true
API=true
DEBUG=INFO
DEBUG=DEBUG
MODE=1
LOOP=20
TIMEOUT=20
MQTT_IP=
MQTT_PORT=1883

View File

@ -0,0 +1,8 @@
DOMAIN = "atc_mi_thermometer_gateway"
def setup(hass, config):
hass.states.set("hello_state.world", "Paulus")
# Return boolean to indicate that initialization was successful.
return True

View File

@ -0,0 +1,68 @@
import json
from requests import api
from dataclasses import dataclass
@dataclass
class EntityState:
timestamp: str
name:str
room:str
temperature:float
humidity:int
battery_percent:int
battery_volt:float
rssi:int
mac:str
def test_api(gateway):
request = f'http://{gateway}:8000/api'
response = api.get(request)
if not response.ok: return False
response_json = json.loads(response.text)
version = response_json['version']['version']
return True
def get_state(gateway, entity_mac) -> EntityState | None:
request = f'http://{gateway}:8000/api/state/{entity_mac}'
response = api.get(request)
if not response.ok: return None
response_json = json.loads(response.text)
return EntityState(response_json['timestamp'],
response_json['name'],
response_json['room'],
response_json['temperature'],
response_json['humidity'],
response_json['battery_percent'],
response_json['battery_volt'],
response_json['rssi'],
entity_mac)
def get_deices(gateway) -> list | None:
request = f'http://{gateway}:8000/api'
response = api.get(request)
if not response.ok: return None
response_json = json.loads(response.text)
return response_json['info']['devices']
def get_device(gateway, entity_mac) -> str | None:
devices = get_deices(gateway)
if entity_mac in devices:
index = devices.index(entity_mac)
return devices[index]
return None
def get_entity_state(entity_mac, gateway):
device = get_device(gateway, entity_mac)
if device is None: return None
entity_state = get_state(gateway, device)
return entity_state

View File

@ -0,0 +1,49 @@
from time import time
from socket import socket
from socket import AF_INET
from socket import SOCK_DGRAM
from socket import IPPROTO_UDP
from socket import SOL_SOCKET
from socket import SO_REUSEADDR
from socket import SO_BROADCAST
DISCOVERY_ACK = 'IP_DISCOVERY_ACK'.encode() # ACK for broadcast
DISCOVERY_OK = 'IP_DISCOVERY_OK'.encode() # Not used yet
DISCOVERY_RSP_GTW = 'IP_DISCOVERY_RSP_GTW'.encode() # RSP for gateway
DISCOVERY_RSP_MSH = 'IP_DISCOVERY_RSP_MSH'.encode() # RSP for mesh
DISCOVERY_TIMEOUT = 0.5
SOCKET_TIMEOUT = 0.2
PORT_SERVER = 9434
PORT_CLIENT = 9435
def start_discovery_client():
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sock.setsockopt(SOL_SOCKET, SO_BROADCAST, 1)
sock.settimeout(SOCKET_TIMEOUT)
server_address = ('255.255.255.255', PORT_SERVER)
start_time_stamp = time()
delta = round(time() - start_time_stamp, 2)
discovered_devices = []
try:
sock.bind(('', PORT_CLIENT))
while delta <= DISCOVERY_TIMEOUT:
delta = round(time() - start_time_stamp, 2)
sock.sendto(DISCOVERY_ACK, server_address)
data, addr = sock.recvfrom(4096)
if str(addr[0]) in discovered_devices: continue
if data == DISCOVERY_RSP_GTW:
discovered_devices.append(str(addr[0]))
except Exception as err:
print(err)
finally:
sock.close()
return discovered_devices[0]

View File

@ -0,0 +1,12 @@
from api import *
from sensor import MiThermometer
def find_all_devices(gateway) -> list:
found_devices = []
devices = get_deices(gateway=gateway)
for device in devices:
device_state = get_state(entity_mac=device, gateway=gateway)
found_devices.append(MiThermometer(state=device_state))
return found_devices

View File

@ -0,0 +1,82 @@
"""Platform for light integration."""
from __future__ import annotations
import logging
from api import *
from .gateway import find_all_devices
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import CONF_NAME, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import DiscoveryInfoType
_LOGGER = logging.getLogger("atc_mi_thermometer_gateway")
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_NAME): cv.string,
vol.Required(CONF_HOST): cv.string,
})
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None):
devices = find_all_devices(gateway=config[CONF_HOST])
for device in devices:
add_entities(device)
class MiThermometer(SensorEntity):
def __init__(self, state:EntityState):
self._mac = state.mac
self._online = False
self._last_update = ""
self._temperature = state.temperature
self._humidity = state.temperature
self._battery_percentage = state.battery_percent
self._rssi = state.rssi
self._name = state.name
self._room = state.room
@property
def mac(self):
return self._mac
@property
def online(self):
return self._online
@property
def get_name(self):
return self._name
@property
def get_room(self):
return self._room
@property
def get_temperature(self):
return self._temperature
@property
def get_humidity(self):
return self._humidity
@property
def get_battery_percentage(self):
return self._battery_percentage
@property
def get_rssi(self):
return self._rssi
def update(self) -> None:
state = get_state(gateway=config[CONF_HOST], entity_mac=self._mac)

View File

@ -1,3 +1,4 @@
import json
import os
from flask import Flask
from flask import jsonify
@ -6,6 +7,8 @@ from flask_cors import CORS
from flask_cors import cross_origin
from check_update import check_for_update
from data_class import LogEntry
class API:
"""
@ -21,8 +24,8 @@ class API:
@self.app.route('/api')
@cross_origin()
def serve_root():
workdir, filename = os.path.split(os.path.abspath(__file__))
update = check_for_update()
root_dict = {
"version": {
"version": os.getenv('VERSION'),
@ -34,16 +37,27 @@ class API:
"name": os.getenv('NAME'),
"info": {
"files_size_sum": self.get_file_size(),
"files": os.listdir(f'{workdir}/data')
"devices": self.get_files()
}
}
return jsonify(root_dict)
@self.app.route('/api/json/<path:path>')
@self.app.route('/api/<path:path>')
@cross_origin()
def serve_json(path):
workdir, filename = os.path.split(os.path.abspath(__file__))
path += '.json'
return send_from_directory(f'{workdir}/data', path)
@self.app.route('/api/state/<path:path>')
@cross_origin()
def serve_entity_state(path):
workdir, filename = os.path.split(os.path.abspath(__file__))
path += '.json'
state = json.load(open(f'{workdir}/data/{path}', mode='r'))
entity_state = LogEntry(data=state)
return jsonify(entity_state.to_json())
@self.app.route('/charts')
@cross_origin()
def serve_index():
@ -58,6 +72,15 @@ class API:
if file.endswith('.json'): sizes += os.path.getsize(f'{workdir}/data/{file}')
return sizes
def get_files(self):
workdir, filename = os.path.split(os.path.abspath(__file__))
files = os.listdir(f'{workdir}/data')
files_list = []
for file in files:
if file.endswith('.json'):
files_list.append(file.replace('.json', ''))
return files_list
api = API()
api.app.run(host='0.0.0.0', port=8000)

View File

@ -18,7 +18,7 @@ class ScanDelegate(DefaultDelegate):
global devices
for (sdid, desc, val) in dev.getScanData():
if self.is_temperature(sdid, val):
if not self.is_temperature(sdid, val): continue
data_obj = Data(val)
if self.is_atc_device(dev, data_obj):
@ -27,9 +27,7 @@ class ScanDelegate(DefaultDelegate):
@staticmethod
def is_temperature(sdid, val):
if sdid != 22: return False
if len(val) != 30: return False
return True
return sdid == 22 and len(val) == 30
@staticmethod
def is_atc_device(dev, data_obj):
@ -40,8 +38,8 @@ class ScanDelegate(DefaultDelegate):
device_from_config = get_device(dev)
try:logger.info(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: {device_from_config.room}")
except:logger.info(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: ?")
room = device_from_config.room if device_from_config.room is not None else '?'
logger.info(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: {room}")
logger.info(f'\tTemp: {data_obj.temperature}°C, Humid: {data_obj.humidity}%, Batt: {data_obj.battery_percent}%')
return True

8
python/src/config.py Normal file
View File

@ -0,0 +1,8 @@
import sys
from dataclasses import dataclass
@dataclass
class Config:
API_PORT:int

View File

@ -37,3 +37,28 @@ class Data:
"battery_volt": self.battery_volt,
"count": self.count,
}
class LogEntry:
def __init__(self, data):
self.name = data['name']
self.room = data['room']
self.timestamp = data['measurements'][-1]['timestamp']
self.temperature = data['measurements'][-1]['temperature']
self.humidity = data['measurements'][-1]['humidity']
self.battery_percent = data['measurements'][-1]['battery_percent']
self.battery_volt = data['measurements'][-1]['battery_volt']
self.rssi = data['measurements'][-1]['rssi']
def to_json(self):
return {
"name": self.name,
"room": self.room,
"timestamp": self.timestamp,
"temperature": self.temperature,
"humidity": self.humidity,
"battery_percent": self.battery_percent,
"battery_volt": self.battery_volt,
"rssi": self.rssi
}

View File

@ -1,4 +1,5 @@
# https://github.com/jholtmann/ip_discovery
"""Find other gateways and serve the udp socket"""
import os
import FindMyIP
@ -8,6 +9,7 @@ from logger import get_logger
DEBUG = True if os.environ.get('DEBUG') is not None else False
DISCOVERY_ACK = 'IP_DISCOVERY_ACK'.encode() # ACK for broadcast
DISCOVERY_OK = 'IP_DISCOVERY_OK'.encode() # Not used yet
DISCOVERY_RSP_GTW = 'IP_DISCOVERY_RSP_GTW'.encode() # RSP for gateway
DISCOVERY_RSP_MSH = 'IP_DISCOVERY_RSP_MSH'.encode() # RSP for mesh
DISCOVERY_TIMEOUT = 0.5
@ -16,39 +18,46 @@ PORT_SERVER = 9434
PORT_CLIENT = 9435
def start_discovery_server():
logger = get_logger(__name__)
def create_udp_socket():
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sock.setsockopt(SOL_SOCKET, SO_BROADCAST, 1)
return sock
def start_discovery_server():
"""Serves the UDP socket for UDP broadcast discovery"""
logger = get_logger(__name__)
sock = create_udp_socket()
server_address = ('', PORT_SERVER)
local_ip = FindMyIP.internal()
try:
sock.bind(server_address)
logger.info("Started discovery socket")
while True:
data, addr = sock.recvfrom(4096)
logger.debug(f"Received a packet from {addr}")
logger.debug(f"{addr[0]} | {local_ip}")
logger.debug(f"{data} | {DISCOVERY_ACK}")
logger.debug(data)
if str(addr[0]) == str(local_ip): continue
logger.debug("IP accepted")
if data == DISCOVERY_ACK:
logger.debug("ACK accepted")
if str(addr[0]) == str(local_ip): continue
logger.debug("IP accepted")
sock.sendto(DISCOVERY_RSP_GTW, (addr[0], PORT_CLIENT))
logger.debug(f"Send ACK to {addr}")
except Exception as err:
logger.error(err)
sock.close()
logger.error(err)
def start_discovery_client():
print("Started discovery client")
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sock.setsockopt(SOL_SOCKET, SO_BROADCAST, 1)
sock = create_udp_socket()
sock.settimeout(SOCKET_TIMEOUT)
server_address = ('255.255.255.255', PORT_SERVER)
start_time_stamp = get_unix_time()
@ -57,14 +66,16 @@ def start_discovery_client():
try:
sock.bind(('', PORT_CLIENT))
while delta <= DISCOVERY_TIMEOUT:
delta = round(get_unix_time() - start_time_stamp, 2)
sock.sendto(DISCOVERY_ACK, server_address)
data, addr = sock.recvfrom(4096)
if data == DISCOVERY_RSP_GTW or data == DISCOVERY_RSP_MSH:
if str(addr[0]) in discovered_devices: continue
print('IP: ' + str(addr[0]))
if data == DISCOVERY_RSP_GTW or data == DISCOVERY_RSP_MSH:
discovered_devices.append(str(addr[0]))
except Exception as err:
print(err)

View File

@ -7,6 +7,18 @@ from logger import get_logger
logger = get_logger(__name__)
def generate_json(device:Device):
dev, data_obj, from_config = device
return {
"timestamp": data_obj.timestamp,
"temperature": data_obj.temperature,
"humidity": data_obj.humidity,
"battery_percent": data_obj.battery_percent,
"battery_volt": data_obj.battery_volt,
"rssi": dev.rssi
}
def log_to_json(devices):
workdir, filename = os.path.split(os.path.abspath(__file__))
@ -20,22 +32,18 @@ def log_to_json(devices):
try:
with open(file_name, 'r') as file: data = json.load(file)
except:
with open(file_name, 'w') as file: file.write("[]")
data = []
measurements = {
"timestamp": data_obj.timestamp,
"temperature": data_obj.temperature,
"humidity": data_obj.humidity,
"battery_percent": data_obj.battery_percent,
"battery_volt": data_obj.battery_volt,
"rssi": dev.rssi,
with open(file_name, 'w') as file:
new_file = {
"name": from_config.name if from_config is not None else "Unknown",
"room": from_config.room if from_config is not None else "Unknown"
"room": from_config.room if from_config is not None else "Unknown",
"measurements": []
}
data.append(measurements)
file.write(json.dumps(new_file))
data = new_file
logger.debug(measurements)
data['measurements'].append(generate_json(device))
# logger.debug(measurements)
with open(file_name, 'w') as file: file.write(json.dumps(data, indent=2))
@ -44,6 +52,5 @@ def log_to_mongodb(data):
pass
def log_to_mqtt(data):
pass

View File

@ -1,13 +1,25 @@
from time import sleep
from log_data import log_to_json
from mqtt import publish_home_assistant_device_config
from mqtt import publish_device_state
from devices import Device
from data_class import Data
from ble_discovery import start_discovery
from logger import get_logger
logger = get_logger(__name__)
def publish_data(devices):
log_to_json(devices)
for device in devices:
publish_home_assistant_device_config(device)
publish_device_state(device)
def start_loop(interval=40, timeout=20):
logger.info(f"Starting loop with interval {interval}s")
while True:
devices = start_discovery(timeout=timeout)
log_to_json(devices)
publish_data(devices)
sleep(interval)

View File

@ -26,7 +26,6 @@ logger.debug(f"VERSION: {os.getenv('VERSION')}")
update_state = check_for_update()
print_state(update_state)
try:
if DOCKER:
logger.info('Running in Docker')
@ -41,5 +40,3 @@ try:
else:
start_loop(interval=40)
except Exception as err:
logger.error(err)

View File

@ -1,15 +1,105 @@
import paho.mqtt.client as MQTT
import os
import json
import paho.mqtt.client as mqtt
topic_root = "/atc_mithermometer_gateway"
from devices import Device
from data_class import Data
from logger import get_logger
mqtt = MQTT.Client(mqtt.CallbackAPIVersion.VERSION2)
mqtt.connect("192.168.178.140", 1883, 60)
topic_root = "homeassistant"
logger = get_logger(__name__)
client = mqtt.Client(client_id='atc_mithermometer_gateway')
IP = os.getenv('MQTT_IP')
PORT = os.getenv('MQTT_PORT')
if IP and PORT:
client.connect(IP, int(PORT), 60)
def publish_measurement(mac):
topic = f"{topic_root}/measurements/{mac}"
topic_temp = f"{topic}/temperature"
topic_humid = f"{topic}/humidity"
topic_battery = f"{topic}/battery"
def generate_json(device:Device):
dev, data_obj, from_config = device
return {
"timestamp": data_obj.timestamp,
"temperature": data_obj.temperature,
"humidity": data_obj.humidity,
"battery_percent": data_obj.battery_percent,
"battery_volt": data_obj.battery_volt,
"rssi": dev.rssi
}
mqtt.publish(topic_temp, )
def generate_config_payloads(device:Device):
mac = '-'.join(device.mac.split(':')[3:])
state_topic = f"atc/device/{mac}/state"
device_json = {
"name": f"ATC {device.name}",
"identifiers":[f"atc-{mac}"],
}
config_temperature_payload = {
"name": f"{device.name} temperature",
"unique_id": f"atc-{mac}-temperature",
"state_topic": state_topic,
"unit_of_measurement": "°C",
"value_template": "{{ value_json.temperature}}",
"device": device_json
}
config_humidity_payload = {
"name": f"{device.name} humidity",
"unique_id": f"atc-{mac}-humidity",
"state_topic": state_topic,
"unit_of_measurement": "%",
"value_template": "{{ value_json.humidity}}",
"device": device_json
}
config_battery_percent_payload = {
"name": f"{device.name} battery",
"unique_id": f"atc-{mac}-battery-percent",
"state_topic": state_topic,
"unit_of_measurement": "%",
"value_template": "{{ value_json.battery_percent}}",
"device": device_json
}
config_rssi_payload = {
"name": f"{device.name} rssi",
"unique_id": f"atc-{mac}-rssi",
"state_topic": state_topic,
"value_template": "{{ value_json.rssi}}",
"device": device_json
}
return [config_temperature_payload, config_humidity_payload, config_battery_percent_payload, config_rssi_payload]
def publish_device_state(device:Device):
dev, data_obj, from_config = device
data_obj: Data
from_config: Device
mac = '-'.join(from_config.mac.split(':')[3:])
state_topic = f"atc/device/{mac}/state"
payload = generate_json(device)
logger.info(f"Publishing {payload}")
client.publish(state_topic, json.dumps(payload))
def publish_home_assistant_device_config(device:Device):
dev, data_obj, from_config = device
data_obj: Data
from_config: Device
mac = '-'.join(from_config.mac.split(':')[3:])
topic = f"{topic_root}/sensor/atc-{mac}/config"
temperature_payload, humidity_payload, battery_percent, rssi_payload = generate_config_payloads(from_config)
client.publish(f'{topic}-temperature/config', json.dumps(temperature_payload))
client.publish(f'{topic}-humidity/config', json.dumps(humidity_payload))
client.publish(f'{topic}-battery/config', json.dumps(battery_percent))
client.publish(f'{topic}-rssi/config', json.dumps(rssi_payload))
# client.publish(f'{topic}-temperature/config', json.dumps({}))
# client.publish(f'{topic}-humidity/config', json.dumps({}))
# client.publish(f'{topic}-battery/config', json.dumps({}))
# client.publish(f'{topic}-rssi/config', json.dumps({}))

View File

@ -1,2 +1,2 @@
from find_gateways import start_discovery_server
from gateway_discovery import start_discovery_server
start_discovery_server()