Compare commits
32 Commits
Author | SHA1 | Date | |
---|---|---|---|
626b8edc88 | |||
c5ed655399 | |||
007ce16618 | |||
12bcbfcca4 | |||
da5506f432 | |||
f107f6b74c | |||
1b3ae9725e | |||
3a8377176f | |||
d314ef37e4 | |||
edb739764a | |||
21f49ae91b | |||
c502ce8f69 | |||
f0b4017166 | |||
67ca83983b | |||
ad0b2a5d99 | |||
109e8cf25f | |||
d88c6f7d7b | |||
926a71e6a8 | |||
20a064dc1f | |||
8417454f5b | |||
ee0efb6c19 | |||
dbdd24822e | |||
78a68f9009 | |||
fbb9aa6665 | |||
51995fc489 | |||
e6b4733a60 | |||
62ce6f1b9c | |||
adcc5a86f8 | |||
39adf907b1 | |||
7abec12620 | |||
046430d1d1 | |||
000510202e |
10
Dockerfile
10
Dockerfile
@ -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
96
deployment/configmap.yml
Normal 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"
|
||||
}
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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
53
env
@ -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": {
|
||||
},
|
||||
"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"
|
||||
"output_converter": "max",
|
||||
"output_topic": "xoutput/bla"
|
||||
},
|
||||
"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
81
env-test
Normal 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
119
src/box.py
Normal 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
46
src/config.py
Normal 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
12
src/converters.py
Normal 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',
|
||||
}
|
||||
}
|
145
src/main.py
145
src/main.py
@ -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)
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user