Compare commits

..

14 Commits

19 changed files with 391 additions and 66 deletions

1
.gitignore vendored
View File

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

View File

@ -38,13 +38,16 @@ 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 data RUN mkdir -p data/log
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

@ -85,9 +85,9 @@ Run Gateway
sh run_gateway.sh sh run_gateway.sh
``` ```
Run Gateway with specified volume for persistence data, loop interval of 40 seconds and interactive mode Run Gateway with specified volume for persistence data, api, loop interval of 40 seconds and interactive mode
```bash ```bash
sh run_gateway.sh --volume /home/username/data --loop 40 --interactive sh run_gateway.sh --volume $PWD/data --loop 40 --interactive --api
``` ```
## Build your own docker container ## Build your own docker container
@ -111,3 +111,4 @@ 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

10
example.env Normal file
View File

@ -0,0 +1,10 @@
BACKGROUND=
TIME_ZONE=
NAME=
INTERACTIVE=true
BUILD=true
API=true
DEBUG=INFO
MODE=1
LOOP=20
TIMEOUT=20

View File

@ -7,4 +7,5 @@ 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,3 +4,5 @@ bs4
requests requests
flask flask
flask_cors flask_cors
FindMyIP
paho-mqtt

View File

@ -4,6 +4,7 @@ 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
class API: class API:
@ -17,22 +18,45 @@ 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():
workdir, filename = os.path.split(os.path.abspath(__file__))
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(),
"files": os.listdir(f'{workdir}/data')
}
}
return jsonify(root_dict)
@self.app.route('/api/json/<path:path>')
@cross_origin()
def serve_json(path):
return send_from_directory(f'{workdir}/data', path)
@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')
@self.app.route('/json') # --------Helpers-------
@cross_origin() def get_file_size(self):
def serve_get_list_of_json():
workdir, filename = os.path.split(os.path.abspath(__file__)) workdir, filename = os.path.split(os.path.abspath(__file__))
return jsonify(os.listdir(f'{workdir}/data')) files = os.listdir(f'{workdir}/data')
sizes = 0
@self.app.route('/json/<path:path>') for file in files:
@cross_origin() if file.endswith('.json'): sizes += os.path.getsize(f'{workdir}/data/{file}')
def serve_json(path): return sizes
workdir, filename = os.path.split(os.path.abspath(__file__))
return send_from_directory(f'{workdir}/data', path)
api = API() api = API()

View File

@ -3,6 +3,8 @@ 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 = []
@ -38,9 +40,9 @@ class ScanDelegate(DefaultDelegate):
device_from_config = get_device(dev) device_from_config = get_device(dev)
try:print(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: {device_from_config.room}") try:logger.info(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: {device_from_config.room}")
except:print(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: ?") except:logger.info(f"Device: {dev.addr.upper()} ({dev.addrType}), RSSI: {dev.rssi}dB, Room: ?")
print(f'\tTemp: {data_obj.temperature}°C, Humid: {data_obj.humidity}%, Batt: {data_obj.battery_percent}%\n') logger.info(f'\tTemp: {data_obj.temperature}°C, Humid: {data_obj.humidity}%, Batt: {data_obj.battery_percent}%')
return True return True
@ -52,7 +54,7 @@ def cleanup():
def start_discovery(timeout=20): def start_discovery(timeout=20):
cleanup() cleanup()
global devices global devices
print(f'Start discovery with timout {timeout}s...') logger.info(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

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

@ -0,0 +1,77 @@
# https://github.com/jholtmann/ip_discovery
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_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_server():
logger = get_logger(__name__)
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sock.setsockopt(SOL_SOCKET, SO_BROADCAST, 1)
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}")
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()
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.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 data == DISCOVERY_RSP_GTW or data == DISCOVERY_RSP_MSH:
if str(addr[0]) in discovered_devices: continue
print('IP: ' + str(addr[0]))
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}")

16
python/src/helpers.py Normal file
View File

@ -0,0 +1,16 @@
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,8 +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
DEBUG = True if os.getenv('DEBUG') == 'true' else False logger = get_logger(__name__)
def log_to_json(devices): def log_to_json(devices):
@ -15,7 +15,7 @@ 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'
print(file_name) if DEBUG else {} logger.debug(f"Save to {file_name}")
try: try:
with open(file_name, 'r') as file: data = json.load(file) with open(file_name, 'r') as file: data = json.load(file)
@ -30,12 +30,12 @@ def log_to_json(devices):
"battery_percent": data_obj.battery_percent, "battery_percent": data_obj.battery_percent,
"battery_volt": data_obj.battery_volt, "battery_volt": data_obj.battery_volt,
"rssi": dev.rssi, "rssi": dev.rssi,
"name": from_config.name, "name": from_config.name if from_config is not None else "Unknown",
"room": from_config.room "room": from_config.room if from_config is not None else "Unknown"
} }
data.append(measurements) data.append(measurements)
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))

21
python/src/logger.py Normal file
View File

@ -0,0 +1,21 @@
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,10 +1,12 @@
from time import sleep from time import sleep
from log_data import log_to_json from log_data import log_to_json
from discovery import start_discovery from ble_discovery import start_discovery
from logger import get_logger
logger = get_logger(__name__)
def start_loop(interval=40, timeout=20): def start_loop(interval=40, timeout=20):
print(f"Starting loop with interval {interval}s") logger.info(f"Starting loop with interval {interval}s")
while True: while True:
devices = start_discovery(timeout=timeout) devices = start_discovery(timeout=timeout)
log_to_json(devices) log_to_json(devices)

View File

@ -1,26 +1,34 @@
import os import os
from discovery import start_discovery from logger import get_logger
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 = True if os.getenv('DOCKER') == 'true' else False DOCKER = os.getenv('DOCKER') == 'true'
DEBUG = True if os.getenv('DEBUG') == 'true' else False DEBUG = os.getenv('DEBUG')
interval = os.getenv('LOOP') interval = os.getenv('LOOP')
timeout = os.getenv('TIMEOUT') timeout = os.getenv('TIMEOUT')
if DEBUG: logger = get_logger(__name__)
print(f"INTERVAL: {INTERVAL}")
print(f"TIMEOUT: {TIMEOUT}")
print(f"interval: {interval}")
print(f"timeout: {timeout}")
print(f"DOCKER: {DOCKER}")
print(f"DEBUG: {DEBUG}")
print("")
logger.debug(f"INTERVAL: {INTERVAL}")
logger.debug(f"TIMEOUT: {TIMEOUT}")
logger.debug(f"interval: {interval}")
logger.debug(f"timeout: {timeout}")
logger.debug(f"DOCKER: {DOCKER}")
logger.debug(f"DEBUG: {DEBUG}")
logger.debug(f"VERSION: {os.getenv('VERSION')}")
update_state = check_for_update()
print_state(update_state)
try:
if DOCKER: if DOCKER:
print("Running in docker") logger.info('Running in Docker')
try:INTERVAL = int(interval) try:INTERVAL = int(interval)
except:pass except:pass
@ -33,3 +41,5 @@ if DOCKER:
else: else:
start_loop(interval=40) start_loop(interval=40)
except Exception as err:
logger.error(err)

View File

@ -1 +1,15 @@
# TODO import paho.mqtt.client as MQTT
topic_root = "/atc_mithermometer_gateway"
mqtt = MQTT.Client(mqtt.CallbackAPIVersion.VERSION2)
mqtt.connect("192.168.178.140", 1883, 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"
mqtt.publish(topic_temp, )

View File

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

View File

@ -1,18 +1,10 @@
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=data VOLUME=$(pwd)/data
BACKGROUND="" HELP="Using any command line argument except -d bypasses the .env file\n\n
TIME_ZONE="" USAGE: sh run_docker.sh [OPTIONS] \n
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
@ -20,6 +12,8 @@ HELP="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
@ -31,12 +25,50 @@ 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"
@ -45,6 +77,15 @@ 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"
@ -73,7 +114,15 @@ docker_run() {
COMMAND="$COMMAND --env API=$API" COMMAND="$COMMAND --env API=$API"
fi fi
if [ "$DEBUG" = true ]; then if [ "$MODE" != 1 ]; 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
@ -81,6 +130,7 @@ 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
@ -93,12 +143,18 @@ 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="-d" BACKGROUND="--detach"
shift shift
;; ;;
--debug ) --debug )
DEBUG=true shift
DEBUG=$1
shift shift
;; ;;
-a | --api) -a | --api)
@ -114,6 +170,15 @@ 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