Compare commits

...

32 Commits
0.0.3 ... main

Author SHA1 Message Date
626b8edc88
feedback
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-24 15:58:51 +01:00
c5ed655399
feedback
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-24 15:56:18 +01:00
007ce16618
feedback
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-24 15:50:34 +01:00
12bcbfcca4
feedback
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-24 15:48:18 +01:00
da5506f432
add window sensor patty street
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-24 13:13:27 +01:00
f107f6b74c
add window contact bad oben
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-24 12:53:45 +01:00
1b3ae9725e
some devices migrated
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-19 14:58:05 +01:00
3a8377176f
config adjusted
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-19 11:23:32 +01:00
d314ef37e4
fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-18 14:54:53 +01:00
edb739764a
fix
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-18 14:43:47 +01:00
21f49ae91b
changes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-18 14:30:28 +01:00
c502ce8f69
context str
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-11-18 14:22:46 +01:00
f0b4017166
refactored
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-11-18 14:17:31 +01:00
67ca83983b
new thermostat
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-12 18:03:46 +01:00
ad0b2a5d99
fix config
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-12 12:37:18 +01:00
109e8cf25f
converters
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-12 12:33:48 +01:00
d88c6f7d7b
converters
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-12 12:29:13 +01:00
926a71e6a8
converters
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-12 12:19:00 +01:00
20a064dc1f
converters
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-12 12:17:49 +01:00
8417454f5b
converters 2024-11-12 12:06:45 +01:00
ee0efb6c19
converters
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-12 12:01:13 +01:00
dbdd24822e
fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 22:15:20 +01:00
78a68f9009
fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 22:10:39 +01:00
fbb9aa6665
fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 22:05:13 +01:00
51995fc489
status text output
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 20:02:03 +01:00
e6b4733a60
overwrite window added
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 19:22:41 +01:00
62ce6f1b9c
logging adjusted
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 14:30:53 +01:00
adcc5a86f8
more logging output
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 14:22:03 +01:00
39adf907b1
configuration and fix concerning window state
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 14:19:00 +01:00
7abec12620
new mqtt callback api
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-07 10:11:04 +01:00
046430d1d1
reload configmap
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-06 17:53:01 +01:00
000510202e
separate configmap and deployment, add reloader
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-11-06 17:47:00 +01:00
11 changed files with 435 additions and 299 deletions

View File

@ -4,7 +4,9 @@ WORKDIR /app
COPY src/requirements.txt .
COPY src/main.py .
COPY src/message_processor.py .
COPY src/converters.py .
COPY src/config.py .
COPY src/box.py .
RUN \
addgroup -S appgroup && \
@ -16,9 +18,9 @@ RUN \
ENV MQTT_BROKER=""
ENV MQTT_PORT=""
ENV MQTT_CLIENT_PREFIX=""
ENV MQTT_BOX_TOPIC_PREFIXES=""
ENV MQTT_CENTRAL_TOPICS=""
ENV MQTT_STATUS_TOPIC=""
ENV BOX_TOPIC_PREFIXES=""
ENV CENTRAL_TOPICS=""
ENV STATUS_TOPIC=""
ENV OFF_TEMPERATURE=""
ENV LOW_TEMPERATURE=""
ENV DEFAULT_HIGH_TEMPERATURE=""

96
deployment/configmap.yml Normal file
View File

@ -0,0 +1,96 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: heating-controller-config
namespace: homea
data:
MQTT_BROKER: "emqx01-anonymous-cluster-internal.broker.svc.cluster.local"
MQTT_PORT: "1883"
MQTT_CLIENT_PREFIX: "HeatingController"
BOX_TOPIC_PREFIXES: |
{
"high_temp": "heating/config/high_temp/",
"overwrite_window": "heating/overwrite_window/",
"cmd": "heating/command/"
}
CENTRAL_TOPICS: |
{
"general_off": "heating/system/general_off",
"maintenance_mode": "heating/system/maintenance_mode",
"status": "heating/system/status",
"cmd": "heating/command/all"
}
STATUS_TOPIC: "heating/status"
CONTEXT_TOPIC_PREFIX: "heating/context/"
OFF_TEMPERATURE: "5.0"
LOW_TEMPERATURE: "15.0"
DEFAULT_HIGH_TEMPERATURE: "21.0"
MAINTENANCE_TEMPERATURE: "30.0"
BOXES: |
{
"patty": {
"windows": {
"garden_right": { "topic": "homegear/instance1/plain/18/1/STATE", "label": "Garten rechts", "converter": "max" },
"garden_left": { "topic": "homegear/instance1/plain/22/1/STATE", "label": "Garten links", "converter": "max" },
"street": { "topic": "zigbee2mqtt/0x00158d000af457cf", "label": "Strasse", "converter": "aqara" }
},
"output_topic": "homegear/instance1/set/39/1/SET_TEMPERATURE",
"output_converter": "max"
},
"kueche": {
"windows": {
"garden_window": { "topic": "zigbee2mqtt/0x00158d008b332785", "label": "Garten Fenster", "converter": "aqara" },
"garden_door": { "topic": "zigbee2mqtt/0x00158d008b332788", "label": "Garten Tuer", "converter": "aqara" },
"street_right": { "topic": "zigbee2mqtt/0x00158d008b151803", "label": "Strasse rechts", "converter": "aqara" },
"street_left": { "topic": "zigbee2mqtt/0x00158d008b331d0b", "label": "Strasse links", "converter": "aqara" }
},
"output_topic": "zigbee2mqtt/0x94deb8fffe2e5c06/set",
"output_converter": "brennenstuhl"
},
"bad_oben": {
"windows": {
"street": { "topic": "zigbee2mqtt/0x00158d008b333aec", "label": "Strasse links", "converter": "aqara" }
},
"output_topic": "homegear/instance1/set/41/1/SET_TEMPERATURE",
"output_converter": "max"
},
"schlafzimmer": {
"windows": {
"street": { "topic": "homegear/instance1/plain/52/1/STATE", "label": "Strasse", "converter": "max" }
},
"output_topic": "homegear/instance1/set/42/1/SET_TEMPERATURE",
"feedback_topic": "homegear/instance1/jsonobj/42/1",
"output_converter": "max"
},
"wolfgang": {
"windows": {
"garden": { "topic": "zigbee2mqtt/0x00158d008b3328da", "label": "Garten", "converter": "aqara" }
},
"output_topic": "zigbee2mqtt/0x540f57fffe7e3cfe/set",
"output_converter": "brennenstuhl"
},
"esszimmer": {
"windows": {
"street_right": { "topic": "homegear/instance1/plain/26/1/STATE", "label": "Strasse rechts", "converter": "max" },
"street_left": { "topic": "homegear/instance1/plain/27/1/STATE", "label": "Strasse links", "converter": "max" }
},
"output_topic": "homegear/instance1/set/45/1/SET_TEMPERATURE",
"output_converter": "max"
},
"wohnzimmer": {
"windows": {
"garden_right": { "topic": "homegear/instance1/plain/28/1/STATE", "label": "Garten rechts", "converter": "max" },
"garden_left": { "topic": "homegear/instance1/plain/29/1/STATE", "label": "Garten links", "converter": "max" }
},
"output_topic": "homegear/instance1/set/46/1/SET_TEMPERATURE",
"output_converter": "max"
},
"bad_unten": {
"windows": {
"street": { "topic": "homegear/instance1/plain/44/1/STATE", "label": "Strasse", "converter": "max" }
},
"output_topic": "homegear/instance1/set/48/1/SET_TEMPERATURE",
"output_converter": "max"
}
}

View File

@ -1,52 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: heating-controller-config
data:
MQTT_BROKER: "emqx01-anonymous-cluster-internal.broker.svc.cluster.local"
MQTT_PORT: "1883"
MQTT_CLIENT_PREFIX: "HeatingController"
MQTT_BOX_TOPIC_PREFIXES: |
{
"high_temp": "heating/config/high_temp/",
"cmd": "heating/command/"
}
MQTT_CENTRAL_TOPICS: |
{
"general_off": "heating/system/general_off",
"maintenance_mode": "heating/system/maintenance_mode",
"status": "heating/system/status"
}
MQTT_STATUS_TOPIC: "heating/status"
OFF_TEMPERATURE: "5.0"
LOW_TEMPERATURE: "15.0"
DEFAULT_HIGH_TEMPERATURE: "21.0"
MAINTENANCE_TEMPERATURE: "30.0"
BOXES: |
{
"box1": {
"label": "living_room",
"windows": [
{ "topic": "window/living_room/street_side", "label": "street_side" },
{ "topic": "window/living_room/garden_side", "label": "garden_side" }
],
"output_topic": "output/living_room"
},
"box2": {
"label": "kitchen",
"windows": [
{ "topic": "window/kitchen/street_side", "label": "street_side" },
{ "topic": "window/kitchen/garden_side", "label": "garden_side" },
{ "topic": "window/kitchen/garden_door", "label": "garden_door" }
],
"output_topic": "output/kitchen"
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: heating-controller
annotations:
configmap.reloader.stakater.com/reload: "heating-controller-config"
spec:
replicas: 1
selector:

View File

@ -16,6 +16,8 @@ kubectl create namespace $NAMESPACE \
-o yaml | \
kubectl -f - apply
kubectl apply -f $DEPLOYMENT_DIR/configmap.yml -n $NAMESPACE
cat $DEPLOYMENT_DIR/deploy-yml.tmpl | \
sed -e 's,%IMAGE%,'$IMAGE_NAME':'$IMAGE_TAG','g | \
kubectl apply -f - -n $NAMESPACE

53
env
View File

@ -1,36 +1,43 @@
export MQTT_BROKER="172.23.1.102"
export MQTT_PORT=1883
export MQTT_CLIENT_PREFIX="MyMQTTClient"
export MQTT_BOX_TOPIC_PREFIXES='{
"high_temp": "heating/config/high_temp/",
"cmd": "heating/command/"
export BOX_TOPIC_PREFIXES='{
"high_temp": "xheating/config/high_temp/",
"cmd": "xheating/command/"
}'
export MQTT_CENTRAL_TOPICS='{
"general_off": "heating/system/general_off",
"maintenance_mode": "heating/system/maintenance_mode",
"status": "heating/system/status"
export CENTRAL_TOPICS='{
"general_off": "xheating/system/general_off",
"maintenance_mode": "xheating/system/maintenance_mode",
"status": "xheating/system/status"
}'
export MQTT_STATUS_TOPIC="heating/status"
export CONTEXT_TOPIC_PREFIX='xheating/context/'
export STATUS_TOPIC="heating/status"
export OFF_TEMPERATURE="5.0"
export LOW_TEMPERATURE="15.0"
export DEFAULT_HIGH_TEMPERATURE="21.0"
export MAINTENANCE_TEMPERATURE="30.0"
export BOXES='{
"box1": {
"label": "living_room",
"windows": [
{ "topic": "window/living_room/street_side", "label": "street_side" },
{ "topic": "window/living_room/garden_side", "label": "garden_side" }
],
"output_topic": "output/living_room"
"bla": {
"windows": {
},
"output_converter": "max",
"output_topic": "xoutput/bla"
},
"box2": {
"label": "kitchen",
"windows": [
{ "topic": "window/kitchen/street_side", "label": "street_side" },
{ "topic": "window/kitchen/garden_side", "label": "garden_side" },
{ "topic": "window/kitchen/garden_door", "label": "garden_door" }
],
"output_topic": "output/kitchen"
"living_room": {
"windows": {
"street_side": { "topic": "window/living_room/street_side", "converter": "max" },
"garden_side": { "topic": "window/living_room/garden_side", "converter": "max" }
},
"output_converter": "max",
"output_topic": "xoutput/living_room"
},
"kitchen": {
"windows": {
"street_side": { "topic": "window/kitchen/street_side", "converter": "max" },
"garden_side": { "topic": "window/kitchen/garden_side", "converter": "max" },
"garden_door": { "topic": "window/kitchen/garden_door", "converter": "max" }
},
"output_converter": "max",
"output_topic": "xoutput/kitchen"
}
}'

81
env-test Normal file
View File

@ -0,0 +1,81 @@
export MQTT_BROKER="172.23.1.102"
export MQTT_PORT=1883
export MQTT_CLIENT_PREFIX="MyMQTTClient"
export BOX_TOPIC_PREFIXES='{
"high_temp": "heating/config/high_temp/",
"cmd": "heating/command/"
}'
export CENTRAL_TOPICS='{
"general_off": "heating/system/general_off",
"maintenance_mode": "heating/system/maintenance_mode",
"status": "heating/system/status"
}'
export CONTEXT_TOPIC_PREFIX='heating/context/'
export STATUS_TOPIC="heating/status"
export OFF_TEMPERATURE="5.0"
export LOW_TEMPERATURE="15.0"
export DEFAULT_HIGH_TEMPERATURE="21.0"
export MAINTENANCE_TEMPERATURE="30.0"
export BOXES='{
"patty": {
"windows": {
"garden_right": { "topic": "homegear/instance1/plain/18/1/STATE", "label": "Garten rechts", "converter": "max" },
"garden_left": { "topic": "homegear/instance1/plain/22/1/STATE", "label": "Garten links", "converter": "max" }
},
"output_topic": "heating/homegear/instance1/set/39/1/SET_TEMPERATURE",
"output_converter": "max"
},
"kueche": {
"windows": {
"garden_window": { "topic": "homegear/instance1/plain/37/1/STATE", "label": "Garten Fenster", "converter": "max" },
"garden_door": { "topic": "homegear/instance1/plain/36/1/STATE", "label": "Garten Tuer", "converter": "max" },
"street_right": { "topic": "homegear/instance1/plain/38/1/STATE", "label": "Strasse rechts", "converter": "max" },
"street_left": { "topic": "homegear/instance1/plain/13/1/STATE", "label": "Strasse links", "converter": "max" }
},
"output_topic": "heating/homegear/instance1/set/40/1/SET_TEMPERATURE",
"output_converter": "max"
},
"bad_oben": {
"windows": {
},
"output_topic": "heating/homegear/instance1/set/41/1/SET_TEMPERATURE",
"output_converter": "max"
},
"schlafzimmer": {
"windows": {
"street": { "topic": "homegear/instance1/plain/52/1/STATE", "label": "Strasse", "converter": "max" }
},
"output_topic": "heating/homegear/instance1/set/42/1/SET_TEMPERATURE",
"output_converter": "max"
},
"wolfgang": {
"windows": {
"garden": { "topic": "zigbee2mqtt/0x00158d008b3328da", "label": "Garten", "converter": "aqara" }
},
"output_topic": "zigbee2mqtt/0x540f57fffe7e3cfe/set",
"output_converter": "brennenstuhl"
},
"esszimmer": {
"windows": {
"street_right": { "topic": "homegear/instance1/plain/26/1/STATE", "label": "Strasse rechts", "converter": "max" },
"street_left": { "topic": "homegear/instance1/plain/27/1/STATE", "label": "Strasse links", "converter": "max" }
},
"output_topic": "heating/homegear/instance1/set/45/1/SET_TEMPERATURE",
"output_converter": "max"
},
"wohnzimmer": {
"windows": {
"garden_right": { "topic": "homegear/instance1/plain/28/1/STATE", "label": "Garten rechts", "converter": "max" },
"garden_left": { "topic": "homegear/instance1/plain/29/1/STATE", "label": "Garten links", "converter": "max" }
},
"output_topic": "heating/homegear/instance1/set/46/1/SET_TEMPERATURE",
"output_converter": "max"
},
"bad_unten": {
"windows": {
"street": { "topic": "homegear/instance1/plain/44/1/STATE", "label": "Strasse", "converter": "max" }
},
"output_topic": "heating/homegear/instance1/set/48/1/SET_TEMPERATURE",
"output_converter": "max"
}
}'

119
src/box.py Normal file
View File

@ -0,0 +1,119 @@
from loguru import logger
import json
from converters import CONVERTERS
from dataclasses import dataclass, field, asdict
@dataclass(init=True, kw_only=True)
class Context:
high_temperature: str
general_off: bool = field(default=False)
maintenance_mode: bool = field(default=False)
overwrite_window: bool = field(default=False)
window_state: dict = field(default_factory=dict)
feedback: dict = field(default_factory=dict)
mode: str = field(default='off')
output_temperature: str = field(default='0')
def __str__(self):
return json.dumps(asdict(self))
class Box:
def __init__(self, box_id, box_config, config):
logger.info(f"[Box {box_id}] Instantiating")
self.id = box_id
self.windows = box_config['windows']
self.output_converter = box_config['output_converter']
self.output_topic = box_config['output_topic']
# we use get here since this key is optional
self.feedback_topic = box_config.get('feedback_topic')
self.config = config
self.context = Context(high_temperature=config.DEFAULT_HIGH_TEMPERATURE,
output_temperature=config.DEFAULT_HIGH_TEMPERATURE,
mode='high',
window_state={ k: 'closed' for k in self.windows.keys() })
self.mqtt_client = None
logger.info(f"[Box {box_id}] Instantiated, context is {self.context}")
def _calculate_output_temperature(self):
# maintenance_mode has the highest priority, even higher than general_off
if self.context.maintenance_mode:
self.context.output_temperature = self.config.MAINTENANCE_TEMPERATURE
return
# general_off has the next highest priority
if self.context.general_off:
self.context.output_temperature = self.config.OFF_TEMPERATURE
return
# an open window shuts off the heating
if not self.context.overwrite_window:
for v in self.context.window_state.values():
if v == 'open':
self.context.output_temperature = self.config.OFF_TEMPERATURE
return
# finally evaluate the mode
if self.context.mode == 'off':
self.context.output_temperature = self.config.OFF_TEMPERATURE
return
if self.context.mode == 'low':
self.context.output_temperature = self.config.LOW_TEMPERATURE
return
if self.context.mode == 'high':
self.context.output_temperature = self.context.high_temperature
return
# if we come here, something serious happened
logger.error(f"Error in calculation of output_temperature: {self.context=}")
return
def handle_message(self, topic_key, payload):
logger.info(f"[Box {self.id}] Handle message for '{topic_key}': {payload}")
try:
# match topic to find operation to be executed
send_command = True
match topic_key.split('/'):
case [ primary_key, sub_key ] if primary_key == 'window':
self.context.window_state[sub_key] = CONVERTERS["window_contact_input"][self.windows[sub_key]["converter"]](payload)
case [ primary_key ] if primary_key == 'high_temp':
self.context.high_temperature = payload
case [ primary_key ] if primary_key == 'cmd':
p = payload.lower()
if p in ('high', 'low', 'off'):
self.context.mode = p
else:
raise Exception(f"Invalid cmd '{payload}'")
case [ primary_key ] if primary_key == 'overwrite_window':
self.context.overwrite_window = payload.lower() == 'true'
case [ primary_key ] if primary_key == 'general_off':
self.context.general_off = payload.lower() == 'true'
case [ primary_key ] if primary_key == 'maintenance_mode':
self.context.maintenance_mode = payload.lower() == 'true'
case [ primary_key ] if primary_key == 'status':
send_command = False
pass
case [ primary_key ] if primary_key == 'feedback':
# merge the both dicts
self.context.feedback |= json.loads(payload)
send_command = False
case _:
raise Error(f"Unexcepted topic_key: {topic_key}, {payload}")
# calculate output temperature from context
self._calculate_output_temperature()
if send_command:
# publish output temperature
result_message = CONVERTERS["target_temperature_output"][self.output_converter](self.context.output_temperature)
publish_topic = self.output_topic
self.mqtt_client.publish(publish_topic, result_message)
logger.info(f"[Box {self.id}] Result published on '{publish_topic}': {result_message}")
# send context in any case
context_topic = f"{self.config.CONTEXT_TOPIC_PREFIX}{self.id}"
self.mqtt_client.publish(context_topic, str(self.context))
except Exception as e:
logger.error(f"[Box {self.id}] Error processing '{topic_key}': {e}")

46
src/config.py Normal file
View File

@ -0,0 +1,46 @@
import os
import json
from dataclasses import dataclass, fields
from typing import Any
class ConfigException (Exception): pass
def default_env_loader(var_name, default_value=None):
v = os.getenv(var_name, default_value)
if not v:
raise ConfigException(var_name)
return v
def json_env_loader(var_name):
v = default_env_loader(var_name)
vv = json.loads(v)
return vv
@dataclass(init=False, frozen=False)
class Config:
MQTT_BROKER : str
MQTT_PORT : int
MQTT_CLIENT_PREFIX : str
BOXES : dict
BOX_TOPIC_PREFIXES : dict
CENTRAL_TOPICS : dict
OFF_TEMPERATURE : str
LOW_TEMPERATURE : str
MAINTENANCE_TEMPERATURE : str
DEFAULT_HIGH_TEMPERATURE : str
STATUS_TOPIC : str
CONTEXT_TOPIC_PREFIX : str
def __init__(self):
for f in fields(self):
v = os.getenv(f.name)
if not v:
raise ConfigException(f.name)
if f.type == int:
v = int(v)
elif f.type == dict:
v = json.loads(v)
setattr(self, f.name, v)

12
src/converters.py Normal file
View File

@ -0,0 +1,12 @@
import json
CONVERTERS = {
"target_temperature_output": {
"max": lambda x: x,
"brennenstuhl": lambda x: json.dumps({"current_heating_setpoint":x}),
},
"window_contact_input": {
"max": lambda x: 'closed' if (x.lower() in ('false', 'close', 'closed')) else 'open',
"aqara": lambda x: 'closed' if json.loads(x)["contact"] else 'open',
}
}

View File

@ -1,131 +1,66 @@
import os
import sys
import json
import uuid
import signal
from loguru import logger
import paho.mqtt.client as mqtt
from message_processor import process_message, prepare_context
from box import Box
from config import Config
# MQTT configuration parameters
BROKER = os.getenv("MQTT_BROKER") # Read broker from environment variable
PORT = int(os.getenv("MQTT_PORT", 1883)) # Default port if not set
BOXES_CONFIG = os.getenv("BOXES") # JSON configuration of boxes from environment variable
BOX_TOPIC_PREFIXES_CONFIG = os.getenv("MQTT_BOX_TOPIC_PREFIXES") # JSON configuration of box-specific topic prefixes
CENTRAL_TOPICS_CONFIG = os.getenv("MQTT_CENTRAL_TOPICS") # JSON configuration of central topics
OFF_TEMPERATURE = os.getenv("OFF_TEMPERATURE", "5.0")
LOW_TEMPERATURE = os.getenv("LOW_TEMPERATURE", "15.0")
DEFAULT_HIGH_TEMPERATURE = os.getenv("DEFAULT_HIGH_TEMPERATURE", "21.0")
MAINTENANCE_TEMPERATURE = os.getenv("MAINTENANCE_TEMPERATURE", "30.0")
STATUS_TOPIC = os.getenv("MQTT_STATUS_TOPIC")
config = Config()
# Check if required environment variables are set
missing_vars = []
if not BROKER:
missing_vars.append('MQTT_BROKER')
if not BOXES_CONFIG:
missing_vars.append('MQTT_BOXES')
if not BOX_TOPIC_PREFIXES_CONFIG:
missing_vars.append('MQTT_BOX_TOPIC_PREFIXES')
if not CENTRAL_TOPICS_CONFIG:
missing_vars.append('MQTT_CENTRAL_TOPICS')
if not STATUS_TOPIC:
missing_vars.append('MQTT_STATUS_TOPIC')
if missing_vars:
logger.error(f"Error: The following environment variables are not set: {', '.join(missing_vars)}")
sys.exit(1)
# context for box operations
context = {}
# configuration values for boxes
context['off_temperature'] = OFF_TEMPERATURE
context['low_temperature'] = LOW_TEMPERATURE
context['default_high_temperature'] = DEFAULT_HIGH_TEMPERATURE
context['maintenance_temperature'] = MAINTENANCE_TEMPERATURE
context['status_topic'] = STATUS_TOPIC
# Load box configurations from JSON
try:
boxes = json.loads(BOXES_CONFIG)
# boxes structure added to global context to give process_message access to it
context['boxes'] = boxes
# add context dict to each box in the list
for box_name, config in boxes.items():
config['context'] = prepare_context(box_name, context)
except json.JSONDecodeError as e:
logger.error(f"Error parsing JSON configuration for boxes: {e}")
sys.exit(1)
# Load box-specific topic prefixes from JSON
try:
box_topic_prefixes = json.loads(BOX_TOPIC_PREFIXES_CONFIG)
# Validation: Check if the required keys are present
required_keys = {'high_temp', 'cmd'}
missing_keys = required_keys - box_topic_prefixes.keys()
if missing_keys:
raise ValueError(f"Error: The following keys are missing in MQTT_BOX_TOPIC_PREFIXES: {', '.join(missing_keys)}")
except (json.JSONDecodeError, ValueError) as e:
logger.error(str(e))
sys.exit(1)
# Load central topics from JSON
try:
central_topics = json.loads(CENTRAL_TOPICS_CONFIG)
except json.JSONDecodeError as e:
logger.error(f"Error parsing JSON configuration for central topics: {e}")
sys.exit(1)
boxes = []
for k, v in config.BOXES.items():
boxes.append(Box(k, v, config))
# Generate CLIENT_ID from UUID and optional prefix
CLIENT_PREFIX = os.getenv("MQTT_CLIENT_PREFIX", "MQTTClient")
CLIENT_ID = f"{CLIENT_PREFIX}_{uuid.uuid4()}"
CLIENT_ID = f"{config.MQTT_CLIENT_PREFIX}_{uuid.uuid4()}"
# Mapping of topics to boxes and topic keys for efficient lookup
topic_mapping = {}
# Callback function for successful connection to the broker
def on_connect(client, userdata, flags, rc):
if rc == 0:
def on_connect(client, userdata, flags, reason_code, properties):
if reason_code == 0:
logger.info("Connected to the broker")
# Subscribe to dynamically generated topics for each box and create mappings
for box_name, config in boxes.items():
label = config.get("label")
if not label:
logger.error(f"[{box_name}] No 'label' defined.")
continue
for box in boxes:
label = box.id
# Generate topics based on configured box-specific prefixes and box label
for topic_key, prefix in box_topic_prefixes.items():
for topic_key, prefix in config.BOX_TOPIC_PREFIXES.items():
topic = f"{prefix}{label}"
client.subscribe(topic)
topic_mapping[topic] = (box_name, topic_key)
logger.info(f"[{box_name}] Subscribed to '{topic}' (Key: '{topic_key}')")
topic_mapping[topic] = (box, topic_key)
logger.info(f"[{box.id}] Subscribed to '{topic}' (Key: '{topic_key}')")
# Subscribe window topics from box
for window_topic in config.get("windows"):
topic = window_topic.get("topic")
label = window_topic.get("label")
for label, window in box.windows.items():
topic = window['topic']
topic_key = f"window/{label}"
client.subscribe(topic)
topic_mapping[topic] = (box_name, topic_key)
logger.info(f"[{box_name}] Subscribed to '{topic}' (Key: '{topic_key}')")
topic_mapping[topic] = (box, topic_key)
logger.info(f"[{box.id}] Subscribed to '{topic}' (Key: '{topic_key}')")
# Subscribe feedback topic if one is available
if box.feedback_topic:
topic = box.feedback_topic
topic_key = "feedback"
client.subscribe(topic)
topic_mapping[topic] = (box, topic_key)
logger.info(f"[{box.id}] Subscribed to '{topic}' (Key: '{topic_key}')")
# Subscribe to central topics and create mappings
for central_key, central_topic in central_topics.items():
for central_key, central_topic in config.CENTRAL_TOPICS.items():
client.subscribe(central_topic)
# Mark central topics with a special key to identify them
topic_mapping[central_topic] = ("__central__", central_key)
logger.info(f"Subscribed to central topic '{central_topic}' (Key: '{central_key}')")
else:
logger.error(f"Connection error with code {rc}")
logger.error(f"Connection error with code {reason_code}")
# Callback function for received messages
def on_message(client, userdata, msg):
@ -134,23 +69,24 @@ def on_message(client, userdata, msg):
payload = msg.payload.decode()
if topic in topic_mapping:
box_name, topic_key = topic_mapping[topic]
if box_name == "__central__":
box, topic_key = topic_mapping[topic]
if box == "__central__":
# Central message, process for all boxes
logger.info(f"[Central] Processing central message for '{topic_key}': {payload}")
for current_box_name in boxes.keys():
process_message(current_box_name, topic_key, payload, context)
for b in boxes:
b.handle_message(topic_key, payload)
else:
# Box-specific message
process_message(box_name, topic_key, payload, context)
logger.info(f"[{box.id}] Processing box-specific message for {topic_key}': {payload}")
box.handle_message(topic_key, payload)
else:
logger.warning(f"Received unknown topic: '{topic}'")
except Exception as e:
logger.error(f"Error processing message from '{msg.topic}': {e}")
# Callback function for disconnection
def on_disconnect(client, userdata, rc):
if rc != 0:
def on_disconnect(client, userdata, flags, reason_code, properties):
if reason_code != 0:
logger.warning("Unexpected disconnection, attempting to reconnect...")
else:
logger.info("Disconnected from the broker.")
@ -161,9 +97,10 @@ def handle_exit_signal(signum, frame):
client.disconnect() # Disconnects from the broker and stops loop_forever()
# Initialize the MQTT client and configure callbacks
client = mqtt.Client(client_id=CLIENT_ID)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID, )
for box in boxes:
box.mqtt_client = client
context['client'] = client
client.on_connect = on_connect
client.on_message = on_message
@ -178,7 +115,7 @@ signal.signal(signal.SIGINT, handle_exit_signal)
# Connect to the broker
try:
client.connect(BROKER, PORT, keepalive=60)
client.connect(config.MQTT_BROKER, config.MQTT_PORT, keepalive=60)
except Exception as e:
logger.error(f"Failed to connect to the broker: {e}")
sys.exit(1)

View File

@ -1,123 +0,0 @@
from loguru import logger
# context
# boxes: structure of boxes
# client: MQTT client
#
# boxes['box_name']['context']
# store here what ever is require to represent the state of the box
def prepare_context(box_name, context):
local_context = {}
local_context['id'] = box_name
local_context['label'] = context['boxes'][box_name]['label']
local_context['high_temperature'] = context['default_high_temperature']
local_context['low_temperature'] = context['low_temperature']
local_context['off_temperature'] = context['off_temperature']
local_context['maintenance_temperature'] = context['maintenance_temperature']
local_context['general_off'] = False
local_context['maintenance_mode'] = False
local_context['window_state'] = {}
for w in context['boxes'][box_name]['windows']:
local_context['window_state'][w['label']] = 'closed'
local_context['mode'] = 'high'
local_context['output_temperature'] = local_context['high_temperature']
return local_context
def process_message(box_name, topic_key, payload, context):
try:
box = context['boxes'][box_name]
local_context = box['context']
logger.info(f"{local_context=}")
logger.info(f"[{box_name}, {box['label']}] Processing message for '{topic_key}': {payload}")
match topic_key.split('/'):
case [ primary_key, sub_key ] if primary_key == 'window':
result = process_window(box_name, context, local_context, sub_key, payload)
case [ primary_key ] if primary_key == 'high_temp':
result = process_high_temp(box_name, context, local_context, payload)
case [ primary_key ] if primary_key == 'cmd':
result = process_cmd(box_name, context, local_context, payload)
case [ primary_key ] if primary_key == 'general_off':
result = process_general_off(box_name, context, local_context, payload)
case [ primary_key ] if primary_key == 'maintenance_mode':
result = process_maintenance_mode(box_name, context, local_context, payload)
case [ primary_key ] if primary_key == 'status':
result = process_status(box_name, context, local_context, payload)
case _:
raise Error(f"Unexcepted topic_key: {topic_key}, {payload}")
if result:
(result_message, status) = result
publish_topic = box["output_topic"] if not status else context['status_topic']
context['client'].publish(publish_topic, result_message)
logger.info(f"[{box_name}] Result published on '{publish_topic}': {status} {result_message}")
except Exception as e:
logger.error(f"[{box_name}] Error processing '{topic_key}': {e}")
def _calculate_output_temperature(local_context):
# maintenance_mode has the highest priority, even higher than general_off
if local_context['maintenance_mode']:
local_context['output_temperature'] = local_context['maintenance_temperature']
return
# general_off has the next highest priority
if local_context['general_off']:
local_context['output_temperature'] = local_context['off_temperature']
return
# an open window shuts off the heating
for w in local_context['window_state'].values():
if w == 'open':
local_context['output_temperature'] = local_context['off_temperature']
return
# finally evaluate the mode
if local_context['mode'] == 'off':
local_context['output_temperature'] = local_context['off_temperature']
return
if local_context['mode'] == 'low':
local_context['output_temperature'] = local_context['low_temperature']
return
if local_context['mode'] == 'high':
local_context['output_temperature'] = local_context['high_temperature']
return
# if we come here, something serious happened
logger.error(f"Error in calculation of output_temperature: {local_context=}")
return
def process_status(box_name, context, local_context, payload):
return (f"{local_context}", True)
def process_general_off(box_name, context, local_context, payload):
local_context['general_off'] = (payload.lower() in ('true'))
_calculate_output_temperature(local_context)
return (local_context['output_temperature'], False)
def process_maintenance_mode(box_name, context, local_context, payload):
local_context['maintenance_mode'] = (payload.lower() in ('true'))
_calculate_output_temperature(local_context)
return (local_context['output_temperature'], False)
def process_cmd(box_name, context, local_context, payload):
if payload.lower() in ('high', 'low', 'off'):
local_context['mode'] = payload.lower()
_calculate_output_temperature(local_context)
else:
logger.error(f"Invalid cmd for {box_name} received: {payload}")
return (local_context['output_temperature'], False)
def process_high_temp(box_name, context, local_context, payload):
local_context['high_temperature'] = payload
_calculate_output_temperature(local_context)
return (local_context['output_temperature'], False)
def process_window(box_name, context, local_context, sub_key, payload):
local_context['window_state'][sub_key] = 'closed' if (payload.lower() in ('true', 'close', 'closed')) else 'open'
_calculate_output_temperature(local_context)
return (local_context['output_temperature'], False)