1 Commits

Author SHA1 Message Date
c72a779865 Merge branch 'develop' into 'main'
Develop

See merge request DasMoorhuhn/atc_mithermometer_gateway!2
2024-07-03 02:57:46 +02:00
30 changed files with 82 additions and 836 deletions

1
.gitignore vendored
View File

@@ -5,4 +5,3 @@ data/
*.json *.json
*.iso *.iso
*.cow *.cow
.env

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -3,8 +3,6 @@ FROM python:3.12-alpine3.20 AS pip_build_stage
COPY ./python/requierements.txt / COPY ./python/requierements.txt /
RUN pip3.12 install setuptools
RUN apk add \ RUN apk add \
make \ make \
git \ git \
@@ -40,16 +38,13 @@ FROM python:3.12-alpine3.20
WORKDIR /src WORKDIR /src
COPY ./python/src/ . COPY ./python/src/ .
COPY ./python/docker_entrypoint.sh / COPY ./python/docker_entrypoint.sh /
RUN mkdir -p data/log RUN mkdir data
VOLUME /src/data VOLUME /src/data
RUN apk add --no-cache sudo bluez tzdata RUN apk add --no-cache sudo bluez tzdata
ENV TZ=Europe/Berlin ENV TZ=Europe/Berlin
ENV DOCKER=true ENV DOCKER=true
ENV API=false ENV API=false
ENV NAME=ATC_MiThermometer_Gateway
ENV VERSION=24-08-29
ENV MODE=1
# Copy pips from the pip build stage # Copy pips from the pip build stage
COPY --from=pip_build_stage /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages COPY --from=pip_build_stage /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages

View File

@@ -22,23 +22,22 @@ 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 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) - 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 - 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:** **TODOs:**
- [WIP] MQTT publishing with discovery for homeassistant - [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
- [TODO] Make a microPython version for using the raspberry pico w or any other microcontroller with BLE and WiFi support - [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] Collect data from multiple devices/gateways
- [TODO] Command line tool for managing the devices - [TODO] Command line tool for managing the devices
- [TODO] Analyzing tool for making statistics - [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] Maybe... a webinterface. But I suck at web stuff, so I don't know.
- [TODO] Implement other BLE Sensors - [TODO] Implement other BLE Sensors
**Current State** **Current State**
![](.media/demo.gif) ![](.media/demo.gif)
![](.media/home_assistant_device.jpg)
![](.media/home_assistant_android_widget.jpg)
## Getting started ## Getting started
@@ -86,9 +85,9 @@ Run Gateway
sh run_gateway.sh sh run_gateway.sh
``` ```
Run Gateway with specified volume for persistence data, api, loop interval of 40 seconds and interactive mode Run Gateway with specified volume for persistence data, loop interval of 40 seconds and interactive mode
```bash ```bash
sh run_gateway.sh --volume $PWD/data --loop 40 --interactive --api sh run_gateway.sh --volume /home/username/data --loop 40 --interactive
``` ```
## Build your own docker container ## Build your own docker container
@@ -112,4 +111,3 @@ Coming when I develop it...
# Resources # Resources
- https://pythonspeed.com/articles/alpine-docker-python this article is nuts :D - https://pythonspeed.com/articles/alpine-docker-python this article is nuts :D
- https://docs.docker.com/build/building/multi-stage/ - https://docs.docker.com/build/building/multi-stage/
- https://github.com/jholtmann/ip_discovery

View File

@@ -5,7 +5,6 @@ HELP="USAGE: sh build_docker.sh \n
[ -t | --tag ] Select a tag for building. Default: latest \n [ -t | --tag ] Select a tag for building. Default: latest \n
[ -i | --image ] Select image tag for building. Default: dasmoorhuhn/atc-mithermometer-gateway \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 [ -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" [ -h | --help ] Get this dialog"
docker buildx version docker buildx version
@@ -25,29 +24,6 @@ build_docker() {
docker buildx build --tag $IMAGE:$TAG --platform=$PLATFORMS --push . 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 while [ "$1" != "" ]; do
case $1 in case $1 in
-t | --tag ) -t | --tag )
@@ -55,12 +31,6 @@ while [ "$1" != "" ]; do
TAG=$1 TAG=$1
shift shift
;; ;;
-r | --release )
shift
TAG=$1
RELEASE=true
shift
;;
-i | --image ) -i | --image )
shift shift
IMAGE=$1 IMAGE=$1
@@ -82,8 +52,4 @@ while [ "$1" != "" ]; do
esac esac
done done
if [ "$RELEASE" = true ]; then build_docker
build_release
else
build_docker
fi

View File

@@ -1,12 +0,0 @@
BACKGROUND=
TIME_ZONE=
NAME=
INTERACTIVE=true
BUILD=true
API=true
DEBUG=DEBUG
MODE=1
LOOP=20
TIMEOUT=20
MQTT_IP=
MQTT_PORT=1883

View File

@@ -1,8 +0,0 @@
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

@@ -1,68 +0,0 @@
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

@@ -1,49 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,82 +0,0 @@
"""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

@@ -7,5 +7,4 @@ if [ "$API" = true ]; then
sleep 1 sleep 1
fi fi
python3.12 start_discovery_server.py &
python3.12 main.py python3.12 main.py

View File

@@ -4,5 +4,3 @@ bs4
requests requests
flask flask
flask_cors flask_cors
FindMyIP
paho-mqtt

View File

@@ -1,13 +1,9 @@
import json
import os import os
from flask import Flask from flask import Flask
from flask import jsonify from flask import jsonify
from flask import send_from_directory from flask import send_from_directory
from flask_cors import CORS from flask_cors import CORS
from flask_cors import cross_origin from flask_cors import cross_origin
from check_update import check_for_update
from data_class import LogEntry
class API: class API:
@@ -21,65 +17,22 @@ class API:
self.app.config['CORS_HEADERS'] = 'Content-Type' self.app.config['CORS_HEADERS'] = 'Content-Type'
# --------Static Routes------- # --------Static Routes-------
@self.app.route('/api')
@cross_origin()
def serve_root():
update = check_for_update()
root_dict = {
"version": {
"version": os.getenv('VERSION'),
"update_available": update.update_available if update is not None else None,
"up_to_date": update.up_to_date if update is not None else None,
"develop_version": update.development if update is not None else None,
},
"mode": os.getenv('MODE'),
"name": os.getenv('NAME'),
"info": {
"files_size_sum": self.get_file_size(),
"devices": self.get_files()
}
}
return jsonify(root_dict)
@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') @self.app.route('/charts')
@cross_origin() @cross_origin()
def serve_index(): def serve_index():
return send_from_directory('/src', 'chart.html') return send_from_directory('/src', 'chart.html')
# --------Helpers------- @self.app.route('/json')
def get_file_size(self): @cross_origin()
def serve_get_list_of_json():
workdir, filename = os.path.split(os.path.abspath(__file__)) workdir, filename = os.path.split(os.path.abspath(__file__))
files = os.listdir(f'{workdir}/data') return jsonify(os.listdir(f'{workdir}/data'))
sizes = 0
for file in files:
if file.endswith('.json'): sizes += os.path.getsize(f'{workdir}/data/{file}')
return sizes
def get_files(self): @self.app.route('/json/<path:path>')
@cross_origin()
def serve_json(path):
workdir, filename = os.path.split(os.path.abspath(__file__)) workdir, filename = os.path.split(os.path.abspath(__file__))
files = os.listdir(f'{workdir}/data') return send_from_directory(f'{workdir}/data', path)
files_list = []
for file in files:
if file.endswith('.json'):
files_list.append(file.replace('.json', ''))
return files_list
api = API() api = API()

View File

@@ -1,74 +0,0 @@
import os
import json
import requests
from logger import get_logger
logger = get_logger(__name__)
class State:
def __init__(self, version_int:int):
self.version_int:int = version_int
self.version_str:str = f"{str(version_int)[0]}{str(version_int)[1]}-{str(version_int)[2]}{str(version_int)[3]}-{str(version_int)[4]}{str(version_int)[5]}"
self.update_available:bool = False
self.up_to_date:bool = False
self.development:bool = False
class Release:
def __init__(self, data:dict):
self.name = data['name']
self.tag_name = data['tag_name']
self.description = data['description']
self.created_at = data['created_at']
self.released_at = data['released_at']
self.upcoming_release = data['upcoming_release']
self.version_int = int(self.tag_name.replace("-", ""))
def check_for_update():
try: version_current = int(os.getenv('VERSION').replace("-", ""))
except:
logger.error("Error getting current version")
return
project_id = 58341398
request = f"https://gitlab.com/api/v4/projects/{project_id}/releases"
response = requests.get(url=request, timeout=1)
if not response.ok: return
releases_json = json.loads(response.text)
# Date, Object
latest = [0, None]
for release_json in releases_json:
release = Release(release_json)
if release.version_int > latest[0]:
latest[0] = release.version_int
latest[1] = release
logger.debug(repr(latest))
release = latest[1]
if release.version_int > version_current:
state = State(release.version_int)
state.update_available = True
return state
elif release.version_int == version_current:
state = State(release.version_int)
state.up_to_date = True
return state
else:
state = State(release.version_int)
state.development = True
return state
def print_state(state:State):
if state is None: return
logger.info(f"Current version: {os.getenv('VERSION')}")
if state.update_available:
logger.info(f"Update available: {state.version_str}")
if state.up_to_date:
logger.info(f"Up to date")
if state.development:
logger.info(f"Development Version")

View File

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

View File

@@ -37,28 +37,3 @@ class Data:
"battery_volt": self.battery_volt, "battery_volt": self.battery_volt,
"count": self.count, "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

@@ -3,8 +3,6 @@ from bluepy.btle import Scanner
from data_class import Data from data_class import Data
from devices import get_device from devices import get_device
from logger import get_logger
logger = get_logger(__name__)
# This is the list, where the responses will be stored from the `handleDiscovery` # This is the list, where the responses will be stored from the `handleDiscovery`
devices = [] devices = []
@@ -18,7 +16,7 @@ class ScanDelegate(DefaultDelegate):
global devices global devices
for (sdid, desc, val) in dev.getScanData(): for (sdid, desc, val) in dev.getScanData():
if not self.is_temperature(sdid, val): continue if self.is_temperature(sdid, val):
data_obj = Data(val) data_obj = Data(val)
if self.is_atc_device(dev, data_obj): if self.is_atc_device(dev, data_obj):
@@ -27,7 +25,9 @@ class ScanDelegate(DefaultDelegate):
@staticmethod @staticmethod
def is_temperature(sdid, val): def is_temperature(sdid, val):
return sdid == 22 and len(val) == 30 if sdid != 22: return False
if len(val) != 30: return False
return True
@staticmethod @staticmethod
def is_atc_device(dev, data_obj): def is_atc_device(dev, data_obj):
@@ -38,9 +38,9 @@ class ScanDelegate(DefaultDelegate):
device_from_config = get_device(dev) device_from_config = get_device(dev)
room = device_from_config.room if device_from_config.room is not None else '?' try:print(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: {device_from_config.room}")
logger.info(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: {room}") except:print(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: ?")
logger.info(f'\tTemp: {data_obj.temperature}°C, Humid: {data_obj.humidity}%, Batt: {data_obj.battery_percent}%') print(f'\tTemp: {data_obj.temperature}°C, Humid: {data_obj.humidity}%, Batt: {data_obj.battery_percent}%\n')
return True return True
@@ -52,7 +52,7 @@ def cleanup():
def start_discovery(timeout=20): def start_discovery(timeout=20):
cleanup() cleanup()
global devices global devices
logger.info(f'Start discovery with timout {timeout}s...') print(f'Start discovery with timout {timeout}s...')
scanner = Scanner().withDelegate(ScanDelegate()) scanner = Scanner().withDelegate(ScanDelegate())
scanner.scan(timeout=timeout, passive=False) scanner.scan(timeout=timeout, passive=False)

View File

@@ -1,88 +0,0 @@
# https://github.com/jholtmann/ip_discovery
"""Find other gateways and serve the udp socket"""
import os
import FindMyIP
from socket import *
from helpers import get_unix_time
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
SOCKET_TIMEOUT = 0.2
PORT_SERVER = 9434
PORT_CLIENT = 9435
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(data)
if str(addr[0]) == str(local_ip): continue
logger.debug("IP accepted")
if data == DISCOVERY_ACK:
logger.debug("ACK accepted")
sock.sendto(DISCOVERY_RSP_GTW, (addr[0], PORT_CLIENT))
logger.debug(f"Send ACK to {addr}")
except Exception as err:
sock.close()
logger.error(err)
def start_discovery_client():
print("Started discovery client")
sock = create_udp_socket()
sock.settimeout(SOCKET_TIMEOUT)
server_address = ('255.255.255.255', PORT_SERVER)
start_time_stamp = get_unix_time()
delta = round(get_unix_time() - start_time_stamp, 2)
discovered_devices = []
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 str(addr[0]) in discovered_devices: continue
if data == DISCOVERY_RSP_GTW or data == DISCOVERY_RSP_MSH:
discovered_devices.append(str(addr[0]))
except Exception as err:
print(err)
finally:
sock.close()
return discovered_devices
devices = start_discovery_client()
print(f"Devices: {devices}")

View File

@@ -1,16 +0,0 @@
import datetime
import time
def get_time_now():
date_now = datetime.datetime.now()
return str(date_now).split(" ")[0]
def get_unix_time():
return time.time()
def get_date():
date_now = datetime.datetime.now()
return str(date_now).split(" ")

View File

@@ -3,20 +3,8 @@ import sys
import json import json
from data_class import Data from data_class import Data
from devices import Device from devices import Device
from logger import get_logger
logger = get_logger(__name__)
DEBUG = True if os.getenv('DEBUG') == 'true' else False
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): def log_to_json(devices):
@@ -27,23 +15,27 @@ def log_to_json(devices):
data_obj: Data data_obj: Data
from_config: Device from_config: Device
file_name = f'{workdir}/data/{str(data_obj.mac).replace(":", "-")}.json' file_name = f'{workdir}/data/{str(data_obj.mac).replace(":", "-")}.json'
logger.debug(f"Save to {file_name}") print(file_name) if DEBUG else {}
try: try:
with open(file_name, 'r') as file: data = json.load(file) with open(file_name, 'r') as file: data = json.load(file)
except: except:
with open(file_name, 'w') as file: with open(file_name, 'w') as file: file.write("[]")
new_file = { data = []
"name": from_config.name if from_config is not None else "Unknown",
"room": from_config.room if from_config is not None else "Unknown", measurements = {
"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,
"name": from_config.name,
"room": from_config.room
} }
file.write(json.dumps(new_file)) data.append(measurements)
data = new_file
data['measurements'].append(generate_json(device)) print(measurements) if DEBUG else {}
# logger.debug(measurements)
with open(file_name, 'w') as file: file.write(json.dumps(data, indent=2)) with open(file_name, 'w') as file: file.write(json.dumps(data, indent=2))
@@ -52,5 +44,6 @@ def log_to_mongodb(data):
pass pass
def log_to_mqtt(data):
pass

View File

@@ -1,21 +0,0 @@
import os
import logging
def get_logger(logger_name:str, log_file='gateway.log'):
logger_name = logger_name.replace('__', '')
debug_level = os.getenv('DEBUG').upper()
if debug_level not in ('CRITICAL', 'ERROR', 'WARNING', 'WARN', 'INFO', 'DEBUG', 'NOTSET', 'FATAL'):
print(f'Loglevel "{debug_level}" is not supported.')
exit(0)
logger = logging.getLogger(logger_name)
logger.setLevel(logging.getLevelName(debug_level))
handler = logging.FileHandler(filename=f'data/{log_file}', encoding='utf-8', mode='a')
formatter = logging.Formatter('%(asctime)s|%(levelname)s|%(name)s|:%(message)s')
handler.setFormatter(formatter)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
logger.addHandler(handler)
logger.info(f"Logger {logger_name} init done")
return logger

View File

@@ -1,25 +1,11 @@
from time import sleep from time import sleep
from log_data import log_to_json from log_data import log_to_json
from mqtt import publish_home_assistant_device_config from discovery import start_discovery
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): def start_loop(interval=40, timeout=20):
logger.info(f"Starting loop with interval {interval}s") print(f"Starting loop with interval {interval}s")
while True: while True:
devices = start_discovery(timeout=timeout) devices = start_discovery(timeout=timeout)
publish_data(devices) log_to_json(devices)
sleep(interval) sleep(interval)

View File

@@ -1,33 +1,26 @@
import os import os
from logger import get_logger from discovery import start_discovery
from ble_discovery import start_discovery
from log_data import log_to_json from log_data import log_to_json
from loop import start_loop from loop import start_loop
from check_update import check_for_update
from check_update import print_state
INTERVAL = 40 INTERVAL = 40
TIMEOUT = 20 TIMEOUT = 20
DOCKER = os.getenv('DOCKER') == 'true' DOCKER = True if os.getenv('DOCKER') == 'true' else False
DEBUG = os.getenv('DEBUG') DEBUG = True if os.getenv('DEBUG') == 'true' else False
interval = os.getenv('LOOP') interval = os.getenv('LOOP')
timeout = os.getenv('TIMEOUT') timeout = os.getenv('TIMEOUT')
logger = get_logger(__name__) if DEBUG:
print(f"INTERVAL: {INTERVAL}")
logger.debug(f"INTERVAL: {INTERVAL}") print(f"TIMEOUT: {TIMEOUT}")
logger.debug(f"TIMEOUT: {TIMEOUT}") print(f"interval: {interval}")
logger.debug(f"interval: {interval}") print(f"timeout: {timeout}")
logger.debug(f"timeout: {timeout}") print(f"DOCKER: {DOCKER}")
logger.debug(f"DOCKER: {DOCKER}") print(f"DEBUG: {DEBUG}")
logger.debug(f"DEBUG: {DEBUG}") print("")
logger.debug(f"VERSION: {os.getenv('VERSION')}")
update_state = check_for_update()
print_state(update_state)
if DOCKER: if DOCKER:
logger.info('Running in Docker') print("Running in docker")
try:INTERVAL = int(interval) try:INTERVAL = int(interval)
except:pass except:pass

View File

@@ -1,105 +1 @@
import os # TODO
import json
import paho.mqtt.client as mqtt
from devices import Device
from data_class import Data
from logger import get_logger
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 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 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 +0,0 @@
from gateway_discovery import start_discovery_server
start_discovery_server()

View File

@@ -1,10 +1,18 @@
TAG="latest" TAG="latest"
CONTAINER="dasmoorhuhn/atc-mithermometer-gateway" CONTAINER="dasmoorhuhn/atc-mithermometer-gateway"
CONTAINER_NAME="ATC_MiThermometer_Gateway" CONTAINER_NAME="ATC_MiThermometer_Gateway"
VOLUME=$(pwd)/data VOLUME=data
HELP="Using any command line argument except -d bypasses the .env file\n\n BACKGROUND=""
USAGE: sh run_docker.sh [OPTIONS] \n TIME_ZONE=""
INTERACTIVE=false
BUILD=false
API=false
DEBUG=false
LOOP="0"
TIMEOUT="0"
HELP="USAGE: sh run_docker.sh [OPTIONS] \n
[ -d ] Run in Backgrund \n [ -d ] Run in Backgrund \n
[ -t | --tag ] Set a docker tag. Default: latest \n [ -t | --tag ] Set a docker tag. Default: latest \n
[ -b | --build ] Build the image before running the container \n [ -b | --build ] Build the image before running the container \n
@@ -12,8 +20,6 @@ USAGE: sh run_docker.sh [OPTIONS] \n
[ -i | --interactive ] Start the container in interactive mode. That means, you can read the console output in real time and abort via STRG+C \n [ -i | --interactive ] Start the container in interactive mode. That means, you can read the console output in real time and abort via STRG+C \n
[ -a | --api ] Start with the API \n [ -a | --api ] Start with the API \n
[ -v | --volume ] Set the volume, where the data from the gateway will be stored. Use relative path like /home/user/gateway/data \n [ -v | --volume ] Set the volume, where the data from the gateway will be stored. Use relative path like /home/user/gateway/data \n
[ -n | --name ] Set a custom name for this gateway \n
[ -m2 | --mesh-gateway ] Set the mode to mesh gateway. This gateway is meant to expand the bluetooth radius. Default mode is main gateway \n
[ -tz | --timezone ] Set the timezone. Default is Europe/Berlin \n [ -tz | --timezone ] Set the timezone. Default is Europe/Berlin \n
[ -to | --timeout ] Set the timeout for the bluetooth scan. default is 20s \n [ -to | --timeout ] Set the timeout for the bluetooth scan. default is 20s \n
[ -h | --help ] Get this dialog \n [ -h | --help ] Get this dialog \n
@@ -25,50 +31,12 @@ if [ "$?" != 0 ]; then
exit 1 exit 1
fi fi
check_for_devices_config() {
if [ ! -f devices.yml ]; then
touch devices.yml
echo 'devices:
- mac: A4:C1:38:00:00:00
name: "my_room"
room: "my_room"' >> devices.yml
fi
}
docker_run() { docker_run() {
sudo killall -9 bluetoothd > /dev/null 2>&1 sudo killall -9 bluetoothd > /dev/null 2>&1
echo Killing old container... echo Killing old container...
docker stop $CONTAINER_NAME > /dev/null 2>&1 docker stop $CONTAINER_NAME > /dev/null 2>&1
docker container rm $CONTAINER_NAME > /dev/null 2>&1 docker container rm $CONTAINER_NAME > /dev/null 2>&1
check_for_devices_config
if [ "$SKIP_ENV" = true ]; then
ENV_EXISTS=false
else
if [ -e .env ]
then
echo Loading .env file
export $(cat .env | xargs)
ENV_EXISTS=true
else
echo No env file found
ENV_EXISTS=false
TIME_ZONE=""
INTERACTIVE=true
BUILD=true
API=true
DEBUG="INFO"
MODE="1"
LOOP="40"
TIMEOUT="20"
fi
fi
COMMAND="docker run $BACKGROUND" COMMAND="docker run $BACKGROUND"
COMMAND="$COMMAND --cap-add=SYS_ADMIN" COMMAND="$COMMAND --cap-add=SYS_ADMIN"
COMMAND="$COMMAND --cap-add=NET_ADMIN" COMMAND="$COMMAND --cap-add=NET_ADMIN"
@@ -77,15 +45,6 @@ docker_run() {
COMMAND="$COMMAND --restart=on-failure" COMMAND="$COMMAND --restart=on-failure"
COMMAND="$COMMAND --volume=/var/run/dbus/:/var/run/dbus/" COMMAND="$COMMAND --volume=/var/run/dbus/:/var/run/dbus/"
COMMAND="$COMMAND --volume=$VOLUME:/src/data" COMMAND="$COMMAND --volume=$VOLUME:/src/data"
COMMAND="$COMMAND --volume=$PWD/devices.yml:/src/devices.yml"
if [ "$ENV_EXISTS" = true ]; then
COMMAND="$COMMAND --env-file .env"
fi
if [ "$BACKGROUND" = "--detach" ]; then
INTERACTIVE=false
fi
if [ "$INTERACTIVE" = true ]; then if [ "$INTERACTIVE" = true ]; then
COMMAND="$COMMAND --interactive" COMMAND="$COMMAND --interactive"
@@ -114,15 +73,7 @@ docker_run() {
COMMAND="$COMMAND --env API=$API" COMMAND="$COMMAND --env API=$API"
fi fi
if [ "$MODE" != 1 ]; then if [ "$DEBUG" = true ]; then
COMMAND="$COMMAND --env MODE=$MODE"
fi
if [ "$NAME" != "" ]; then
COMMAND="$COMMAND --env NAME=$NAME"
fi
if [ "$DEBUG" = "DEBUG" ]; then
COMMAND="$COMMAND --env DEBUG=$DEBUG" COMMAND="$COMMAND --env DEBUG=$DEBUG"
COMMAND="$COMMAND $CONTAINER:$TAG" COMMAND="$COMMAND $CONTAINER:$TAG"
echo echo
@@ -130,7 +81,6 @@ docker_run() {
echo echo
echo DEBUG MODE echo DEBUG MODE
else else
COMMAND="$COMMAND --env DEBUG=$DEBUG"
COMMAND="$COMMAND $CONTAINER:$TAG" COMMAND="$COMMAND $CONTAINER:$TAG"
fi fi
@@ -143,18 +93,12 @@ docker_run() {
while [ "$1" != "" ]; do while [ "$1" != "" ]; do
case $1 in case $1 in
-se | --skip-env-file )
SKIP_ENV=true
echo "Skip env file"
shift
;;
-d ) -d )
BACKGROUND="--detach" BACKGROUND="-d"
shift shift
;; ;;
--debug ) --debug )
shift DEBUG=true
DEBUG=$1
shift shift
;; ;;
-a | --api) -a | --api)
@@ -170,15 +114,6 @@ while [ "$1" != "" ]; do
VOLUME=$1 VOLUME=$1
shift shift
;; ;;
-n | --name )
shift
NAME=$1
shift
;;
-m2 | --mesh-gateway)
MODE=2
shift
;;
-tz | --timezone ) -tz | --timezone )
shift shift
TIME_ZONE=$1 TIME_ZONE=$1