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/requirements.txt .
COPY src/main.py . COPY src/main.py .
COPY src/message_processor.py . COPY src/converters.py .
COPY src/config.py .
COPY src/box.py .
RUN \ RUN \
addgroup -S appgroup && \ addgroup -S appgroup && \
@ -16,9 +18,9 @@ RUN \
ENV MQTT_BROKER="" ENV MQTT_BROKER=""
ENV MQTT_PORT="" ENV MQTT_PORT=""
ENV MQTT_CLIENT_PREFIX="" ENV MQTT_CLIENT_PREFIX=""
ENV MQTT_BOX_TOPIC_PREFIXES="" ENV BOX_TOPIC_PREFIXES=""
ENV MQTT_CENTRAL_TOPICS="" ENV CENTRAL_TOPICS=""
ENV MQTT_STATUS_TOPIC="" ENV STATUS_TOPIC=""
ENV OFF_TEMPERATURE="" ENV OFF_TEMPERATURE=""
ENV LOW_TEMPERATURE="" ENV LOW_TEMPERATURE=""
ENV DEFAULT_HIGH_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 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: heating-controller name: heating-controller
annotations:
configmap.reloader.stakater.com/reload: "heating-controller-config"
spec: spec:
replicas: 1 replicas: 1
selector: selector:

View File

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

53
env
View File

@ -1,36 +1,43 @@
export MQTT_BROKER="172.23.1.102" export MQTT_BROKER="172.23.1.102"
export MQTT_PORT=1883 export MQTT_PORT=1883
export MQTT_CLIENT_PREFIX="MyMQTTClient" export MQTT_CLIENT_PREFIX="MyMQTTClient"
export MQTT_BOX_TOPIC_PREFIXES='{ export BOX_TOPIC_PREFIXES='{
"high_temp": "heating/config/high_temp/", "high_temp": "xheating/config/high_temp/",
"cmd": "heating/command/" "cmd": "xheating/command/"
}' }'
export MQTT_CENTRAL_TOPICS='{ export CENTRAL_TOPICS='{
"general_off": "heating/system/general_off", "general_off": "xheating/system/general_off",
"maintenance_mode": "heating/system/maintenance_mode", "maintenance_mode": "xheating/system/maintenance_mode",
"status": "heating/system/status" "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 OFF_TEMPERATURE="5.0"
export LOW_TEMPERATURE="15.0" export LOW_TEMPERATURE="15.0"
export DEFAULT_HIGH_TEMPERATURE="21.0" export DEFAULT_HIGH_TEMPERATURE="21.0"
export MAINTENANCE_TEMPERATURE="30.0" export MAINTENANCE_TEMPERATURE="30.0"
export BOXES='{ export BOXES='{
"box1": { "bla": {
"label": "living_room", "windows": {
"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": { "output_converter": "max",
"label": "kitchen", "output_topic": "xoutput/bla"
"windows": [ },
{ "topic": "window/kitchen/street_side", "label": "street_side" }, "living_room": {
{ "topic": "window/kitchen/garden_side", "label": "garden_side" }, "windows": {
{ "topic": "window/kitchen/garden_door", "label": "garden_door" } "street_side": { "topic": "window/living_room/street_side", "converter": "max" },
], "garden_side": { "topic": "window/living_room/garden_side", "converter": "max" }
"output_topic": "output/kitchen" },
"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 sys
import json import json
import uuid import uuid
import signal import signal
from loguru import logger from loguru import logger
import paho.mqtt.client as mqtt 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 config = Config()
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")
# Check if required environment variables are set boxes = []
missing_vars = [] for k, v in config.BOXES.items():
if not BROKER: boxes.append(Box(k, v, config))
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)
# Generate CLIENT_ID from UUID and optional prefix # Generate CLIENT_ID from UUID and optional prefix
CLIENT_PREFIX = os.getenv("MQTT_CLIENT_PREFIX", "MQTTClient") CLIENT_ID = f"{config.MQTT_CLIENT_PREFIX}_{uuid.uuid4()}"
CLIENT_ID = f"{CLIENT_PREFIX}_{uuid.uuid4()}"
# Mapping of topics to boxes and topic keys for efficient lookup # Mapping of topics to boxes and topic keys for efficient lookup
topic_mapping = {} topic_mapping = {}
# Callback function for successful connection to the broker # Callback function for successful connection to the broker
def on_connect(client, userdata, flags, rc): def on_connect(client, userdata, flags, reason_code, properties):
if rc == 0: if reason_code == 0:
logger.info("Connected to the broker") logger.info("Connected to the broker")
# Subscribe to dynamically generated topics for each box and create mappings # Subscribe to dynamically generated topics for each box and create mappings
for box_name, config in boxes.items(): for box in boxes:
label = config.get("label") label = box.id
if not label:
logger.error(f"[{box_name}] No 'label' defined.")
continue
# Generate topics based on configured box-specific prefixes and box label # 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}" topic = f"{prefix}{label}"
client.subscribe(topic) client.subscribe(topic)
topic_mapping[topic] = (box_name, topic_key) topic_mapping[topic] = (box, topic_key)
logger.info(f"[{box_name}] Subscribed to '{topic}' (Key: '{topic_key}')") logger.info(f"[{box.id}] Subscribed to '{topic}' (Key: '{topic_key}')")
# Subscribe window topics from box # Subscribe window topics from box
for window_topic in config.get("windows"): for label, window in box.windows.items():
topic = window_topic.get("topic") topic = window['topic']
label = window_topic.get("label")
topic_key = f"window/{label}" topic_key = f"window/{label}"
client.subscribe(topic) client.subscribe(topic)
topic_mapping[topic] = (box_name, topic_key) topic_mapping[topic] = (box, topic_key)
logger.info(f"[{box_name}] Subscribed to '{topic}' (Key: '{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 # 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) client.subscribe(central_topic)
# Mark central topics with a special key to identify them # Mark central topics with a special key to identify them
topic_mapping[central_topic] = ("__central__", central_key) topic_mapping[central_topic] = ("__central__", central_key)
logger.info(f"Subscribed to central topic '{central_topic}' (Key: '{central_key}')") logger.info(f"Subscribed to central topic '{central_topic}' (Key: '{central_key}')")
else: else:
logger.error(f"Connection error with code {rc}") logger.error(f"Connection error with code {reason_code}")
# Callback function for received messages # Callback function for received messages
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
@ -134,23 +69,24 @@ def on_message(client, userdata, msg):
payload = msg.payload.decode() payload = msg.payload.decode()
if topic in topic_mapping: if topic in topic_mapping:
box_name, topic_key = topic_mapping[topic] box, topic_key = topic_mapping[topic]
if box_name == "__central__": if box == "__central__":
# Central message, process for all boxes # Central message, process for all boxes
logger.info(f"[Central] Processing central message for '{topic_key}': {payload}") logger.info(f"[Central] Processing central message for '{topic_key}': {payload}")
for current_box_name in boxes.keys(): for b in boxes:
process_message(current_box_name, topic_key, payload, context) b.handle_message(topic_key, payload)
else: else:
# Box-specific message # 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: else:
logger.warning(f"Received unknown topic: '{topic}'") logger.warning(f"Received unknown topic: '{topic}'")
except Exception as e: except Exception as e:
logger.error(f"Error processing message from '{msg.topic}': {e}") logger.error(f"Error processing message from '{msg.topic}': {e}")
# Callback function for disconnection # Callback function for disconnection
def on_disconnect(client, userdata, rc): def on_disconnect(client, userdata, flags, reason_code, properties):
if rc != 0: if reason_code != 0:
logger.warning("Unexpected disconnection, attempting to reconnect...") logger.warning("Unexpected disconnection, attempting to reconnect...")
else: else:
logger.info("Disconnected from the broker.") 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() client.disconnect() # Disconnects from the broker and stops loop_forever()
# Initialize the MQTT client and configure callbacks # 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_connect = on_connect
client.on_message = on_message client.on_message = on_message
@ -178,7 +115,7 @@ signal.signal(signal.SIGINT, handle_exit_signal)
# Connect to the broker # Connect to the broker
try: try:
client.connect(BROKER, PORT, keepalive=60) client.connect(config.MQTT_BROKER, config.MQTT_PORT, keepalive=60)
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to the broker: {e}") logger.error(f"Failed to connect to the broker: {e}")
sys.exit(1) 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)