v0.3.0 - 2025-02-27 Added poller functionality. EHS-Sentinel can now actively request values via Modbus fetch_intervals and groups can be defined in the config file default group and pollers are in the sampelconfig [!CAUTION] This functionality requires that EHS-Sentinel actively communicates with the Samsung EHS, so EHS-Sentinel intervenes here in the Modbus data traffic between the components (it sends its own messages). The activation of this functionality is exclusively at your own risk. I assume no liability for any damage caused. added a homeassistant dashboard.yaml with default Dashboard edited Measurement ENUM_IN_FSV_5061 add enums ENUM_IN_FSV_5094 correct enum values ENUM_IN_PV_CONTACT_STATE correct enum values added units for multiple Measurements Rename some Measurements: NASA_INDOOR_COOL_MAX_SETTEMP_WATEROUT -> VAR_IN_FSV_1011 NASA_INDOOR_COOL_MIN_SETTEMP_WATEROUT -> VAR_IN_FSV_1012 NASA_INDOOR_COOL_MAX_SETTEMP_ROOM -> VAR_IN_FSV_1021 NASA_INDOOR_COOL_MIN_SETTEMP_ROOM -> VAR_IN_FSV_1022 NASA_INDOOR_HEAT_MAX_SETTEMP_WATEROUT -> VAR_IN_FSV_1031 NASA_INDOOR_HEAT_MIN_SETTEMP_WATEROUT -> VAR_IN_FSV_1032 NASA_INDOOR_HEAT_MAX_SETTEMP_ROOM -> VAR_IN_FSV_1041 NASA_INDOOR_HEAT_MIN_SETTEMP_ROOM -> VAR_IN_FSV_1042 NASA_DHW_MAX_SETTEMPLIMIT -> VAR_IN_FSV_1051 NASA_DHW_MIN_SETTEMPLIMIT -> VAR_IN_FSV_1052 NASA_USE_DHW_THERMOSTAT -> ENUM_IN_FSV_3061 NASA_USE_BOOSTER_HEATER -> ENUM_IN_FSV_3031 NASA_ENABLE_DHW -> ENUM_IN_FSV_3011 NASA_USE_THERMOSTAT!1 -> ENUM_IN_FSV_2091 NASA_USE_THERMOSTAT2 -> ENUM_IN_FSV_2092 Fixes #9
455 lines
21 KiB
Python
455 lines
21 KiB
Python
import asyncio
|
|
import os
|
|
import signal
|
|
import json
|
|
import time
|
|
|
|
import gmqtt
|
|
|
|
# Get the logger
|
|
from CustomLogger import logger
|
|
from EHSArguments import EHSArguments
|
|
from EHSConfig import EHSConfig
|
|
|
|
class MQTTClient:
|
|
"""
|
|
MQTTClient is a singleton class that manages the connection and communication with an MQTT broker.
|
|
It handles the initialization, connection, subscription, and message publishing for the MQTT client.
|
|
The class also supports Home Assistant auto-discovery and maintains a list of known devices.
|
|
"""
|
|
_instance = None
|
|
STOP = asyncio.Event()
|
|
|
|
DEVICE_ID = "samsung_ehssentinel"
|
|
|
|
def __new__(cls, *args, **kwargs):
|
|
"""
|
|
Create a new instance of the class if one does not already exist.
|
|
This method ensures that only one instance of the class is created (singleton pattern).
|
|
If an instance already exists, it returns the existing instance.
|
|
Otherwise, it creates a new instance, marks it as uninitialized, and returns it.
|
|
Args:
|
|
cls: The class being instantiated.
|
|
*args: Variable length argument list.
|
|
**kwargs: Arbitrary keyword arguments.
|
|
Returns:
|
|
An instance of the class.
|
|
"""
|
|
|
|
if not cls._instance:
|
|
cls._instance = super(MQTTClient, cls).__new__(cls)
|
|
cls._instance._initialized = False
|
|
return cls._instance
|
|
|
|
def __init__(self):
|
|
"""
|
|
Initializes the MQTTClient instance.
|
|
This constructor sets up the MQTT client with the necessary configuration
|
|
parameters, including broker URL, port, client ID, and authentication credentials.
|
|
It also assigns callback functions for various MQTT events such as connect,
|
|
disconnect, message, and subscribe. Additionally, it initializes topic-related
|
|
settings and a list to keep track of known topics.
|
|
Attributes:
|
|
config (EHSConfig): Configuration object for MQTT settings.
|
|
args (EHSArguments): Argument parser object.
|
|
broker (str): URL of the MQTT broker.
|
|
port (int): Port number of the MQTT broker.
|
|
client_id (str): Client ID for the MQTT connection.
|
|
client (gmqtt.Client): MQTT client instance.
|
|
topicPrefix (str): Prefix for MQTT topics.
|
|
homeAssistantAutoDiscoverTopic (str): Topic for Home Assistant auto-discovery.
|
|
useCamelCaseTopicNames (bool): Flag to use camel case for topic names.
|
|
initialized (bool): Flag indicating if the client has been initialized.
|
|
known_topics (list): List to keep track of known topics.
|
|
known_devices_topic (str): Topic for storing known devices.
|
|
"""
|
|
|
|
if self._initialized:
|
|
return
|
|
self.config = EHSConfig()
|
|
self.args = EHSArguments()
|
|
self._initialized = True
|
|
self.broker = self.config.MQTT['broker-url']
|
|
self.port = self.config.MQTT['broker-port']
|
|
self.client_id = self.config.MQTT['client-id']
|
|
self.client = gmqtt.Client(self.client_id)
|
|
self.client.on_connect = self.on_connect
|
|
self.client.on_disconnect = self.on_disconnect
|
|
self.client.on_message = self.on_message
|
|
self.client.on_subscribe = self.on_subscribe
|
|
if self.config.MQTT['user'] and self.config.MQTT['password']:
|
|
self.client.set_auth_credentials(self.config.MQTT['user'], self.config.MQTT['password'])
|
|
self.topicPrefix = self.config.MQTT['topicPrefix']
|
|
self.homeAssistantAutoDiscoverTopic = self.config.MQTT['homeAssistantAutoDiscoverTopic']
|
|
self.useCamelCaseTopicNames = self.config.MQTT['useCamelCaseTopicNames']
|
|
|
|
self.initialized = True
|
|
self.known_topics: list = list() # Set to keep track of known topics
|
|
self.known_devices_topic = "known/devices" # Dedicated topic for storing known topics
|
|
|
|
async def connect(self):
|
|
"""
|
|
Asynchronously connects to the MQTT broker and optionally clears the known devices topic.
|
|
This function logs the connection attempt, connects to the MQTT broker using the specified
|
|
broker address and port, and sets the keepalive interval. If the CLEAN_KNOWN_DEVICES argument
|
|
is set, it publishes an empty message to the known devices topic to clear it.
|
|
Args:
|
|
None
|
|
Returns:
|
|
None
|
|
"""
|
|
|
|
logger.info("[MQTT] Connecting to broker...")
|
|
await self.client.connect(self.broker, self.port, keepalive=60, version=gmqtt.constants.MQTTv311)
|
|
|
|
if self.args.CLEAN_KNOWN_DEVICES:
|
|
self._publish(f"{self.topicPrefix.replace('/', '')}/{self.known_devices_topic}", " ", retain=True)
|
|
logger.info("Known Devices Topic have been cleared")
|
|
|
|
def subscribe_known_topics(self):
|
|
"""
|
|
Subscribe to predefined MQTT topics.
|
|
This method subscribes the MQTT client to a set of known topics, which include:
|
|
- A topic for known devices, constructed using the topic prefix and known devices topic.
|
|
- A status topic for Home Assistant auto-discovery.
|
|
The subscription is done with a QoS level of 1 for both topics.
|
|
Logging:
|
|
- Logs an info message indicating the subscription to known devices topic.
|
|
"""
|
|
|
|
logger.info("Subscribe to known devices topic")
|
|
self.client.subscribe(
|
|
[
|
|
gmqtt.Subscription(f"{self.topicPrefix.replace('/', '')}/{self.known_devices_topic}", 1),
|
|
gmqtt.Subscription(f"{self.homeAssistantAutoDiscoverTopic}/status", 1)
|
|
]
|
|
)
|
|
|
|
def on_subscribe(self, client, mid, qos, properties):
|
|
"""
|
|
Callback function that is called when the client subscribes to a topic.
|
|
Args:
|
|
client (paho.mqtt.client.Client): The client instance for this callback.
|
|
mid (int): The message ID for the subscribe request.
|
|
qos (int): The Quality of Service level for the subscription.
|
|
properties (paho.mqtt.properties.Properties): The properties associated with the subscription.
|
|
Returns:
|
|
None
|
|
"""
|
|
|
|
logger.debug('SUBSCRIBED')
|
|
|
|
def on_message(self, client, topic, payload, qos, properties):
|
|
"""
|
|
Callback function that is triggered when a message is received on a subscribed topic.
|
|
Args:
|
|
client (paho.mqtt.client.Client): The MQTT client instance.
|
|
topic (str): The topic that the message was received on.
|
|
payload (bytes): The message payload.
|
|
qos (int): The quality of service level of the message.
|
|
properties (paho.mqtt.properties.Properties): The properties associated with the message.
|
|
This function performs the following actions:
|
|
- If the topic matches the known devices topic, it updates the known topics list with the retained message.
|
|
- If the topic matches the Home Assistant auto-discover status topic, it logs the status message and, if the payload indicates that Home Assistant is online, it clears the known devices topic.
|
|
"""
|
|
|
|
if self.known_devices_topic in topic:
|
|
# Update the known devices set with the retained message
|
|
self.known_topics = list(filter(None, [x.strip() for x in payload.decode().split(",")]))
|
|
if properties['retain'] == True:
|
|
if self.config.LOGGING['deviceAdded']:
|
|
logger.info(f"Loaded devices from known devices Topic:")
|
|
|
|
for idx, devname in enumerate(self.known_topics, start=1):
|
|
logger.info(f"Device no. {idx:<3}: {devname} ")
|
|
else:
|
|
logger.debug(f"Loaded devices from known devices Topic:")
|
|
for idx, devname in enumerate(self.known_topics):
|
|
logger.debug(f"Device added no. {idx:<3}: {devname} ")
|
|
|
|
if f"{self.homeAssistantAutoDiscoverTopic}/status" == topic:
|
|
logger.info(f"HASS Status Messages {topic} received: {payload.decode()}")
|
|
if payload.decode() == "online":
|
|
self._publish(f"{self.topicPrefix.replace('/', '')}/{self.known_devices_topic}", " ", retain=True)
|
|
logger.info("Known Devices Topic have been cleared")
|
|
self.clear_hass()
|
|
logger.info("All configuration from HASS has been resetet")
|
|
|
|
def on_connect(self, client, flags, rc, properties):
|
|
"""
|
|
Callback function for when the client receives a CONNACK response from the server.
|
|
Parameters:
|
|
client (paho.mqtt.client.Client): The client instance for this callback.
|
|
flags (dict): Response flags sent by the broker.
|
|
rc (int): The connection result.
|
|
properties (paho.mqtt.properties.Properties): The properties associated with the connection.
|
|
If the connection is successful (rc == 0), logs a success message and subscribes to known topics if any.
|
|
Otherwise, logs an error message with the return code.
|
|
"""
|
|
|
|
if rc == 0:
|
|
logger.info(f"Connected to MQTT with result code {rc}")
|
|
if len(self.homeAssistantAutoDiscoverTopic) > 0:
|
|
self.subscribe_known_topics()
|
|
else:
|
|
logger.error(f"Failed to connect, return code {rc}")
|
|
|
|
def on_disconnect(self, client, packet, exc=None):
|
|
"""
|
|
Callback function that is called when the client disconnects from the MQTT broker.
|
|
This function logs the disconnection event and attempts to reconnect the client
|
|
in case of an unexpected disconnection. It will keep trying to reconnect every
|
|
5 seconds until successful.
|
|
Args:
|
|
client (paho.mqtt.client.Client): The MQTT client instance that disconnected.
|
|
packet (paho.mqtt.packet.Packet): The disconnect packet.
|
|
exc (Exception, optional): The exception that caused the disconnection, if any.
|
|
"""
|
|
|
|
logger.info(f"Disconnected with result code ")
|
|
logger.warning("Unexpected disconnection. Reconnecting...")
|
|
while True:
|
|
try:
|
|
self.client.reconnect()
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Reconnection failed: {e}")
|
|
time.sleep(5)
|
|
|
|
def _publish(self, topic, payload, qos=0, retain=False):
|
|
"""
|
|
Publishes a message to a specified MQTT topic.
|
|
Args:
|
|
topic (str): The MQTT topic to publish to.
|
|
payload (str): The message payload to publish.
|
|
qos (int, optional): The Quality of Service level for the message. Defaults to 0.
|
|
retain (bool, optional): If True, the message will be retained by the broker. Defaults to False.
|
|
Returns:
|
|
None
|
|
"""
|
|
|
|
logger.debug(f"MQTT Publish Topic: {topic} payload: {payload}")
|
|
self.client.publish(f"{topic}", payload, qos, retain)
|
|
#time.sleep(0.1)
|
|
|
|
def refresh_known_devices(self, devname):
|
|
"""
|
|
Refreshes the list of known devices by publishing the current known topics to the MQTT broker.
|
|
Args:
|
|
devname (str): The name of the device to refresh.
|
|
This function constructs a topic string by replacing '/' with an empty string in the topicPrefix,
|
|
then concatenates it with the known_devices_topic. It publishes the known topics as a comma-separated
|
|
string to this constructed topic with the retain flag set to True.
|
|
"""
|
|
self.known_topics.append(devname)
|
|
if self.config.LOGGING['deviceAdded']:
|
|
logger.info(f"Device added no. {len(self.known_topics):<3}: {devname} ")
|
|
else:
|
|
logger.debug(f"Device added no. {len(self.known_topics):<3}: {devname} ")
|
|
self._publish(f"{self.topicPrefix.replace('/', '')}/{self.known_devices_topic}", ",".join(self.known_topics), retain=True)
|
|
|
|
def publish_message(self, name, value):
|
|
"""
|
|
Publishes a message to an MQTT topic.
|
|
This function normalizes the given name, determines the appropriate MQTT topic,
|
|
and publishes the provided value to that topic. It also handles Home Assistant
|
|
auto-discovery if configured.
|
|
Args:
|
|
name (str): The name of the sensor or device.
|
|
value (int, float, bool, str): The value to be published. If the value is a float,
|
|
it will be rounded to two decimal places.
|
|
Raises:
|
|
ValueError: If the value type is not supported for publishing.
|
|
"""
|
|
|
|
newname = f"{self._normalize_name(name)}"
|
|
|
|
if len(self.homeAssistantAutoDiscoverTopic) > 0:
|
|
|
|
if name not in self.known_topics:
|
|
self.auto_discover_hass(name)
|
|
self.refresh_known_devices(name)
|
|
|
|
time.sleep(1)
|
|
|
|
sensor_type = "sensor"
|
|
if 'enum' in self.config.NASA_REPO[name]:
|
|
enum = [*self.config.NASA_REPO[name]['enum'].values()]
|
|
if all([en.lower() in ['on', 'off'] for en in enum]):
|
|
sensor_type = "binary_sensor"
|
|
topicname = f"{self.config.MQTT['homeAssistantAutoDiscoverTopic']}/{sensor_type}/{self.DEVICE_ID}_{newname.lower()}/state"
|
|
else:
|
|
topicname = f"{self.topicPrefix.replace('/', '')}/{newname}"
|
|
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
value = round(value, 2) if isinstance(value, float) and "." in f"{value}" else value
|
|
|
|
self._publish(topicname, value, qos=2, retain=False)
|
|
|
|
def clear_hass(self):
|
|
"""
|
|
clears all entities/components fpr the HomeAssistant Device
|
|
"""
|
|
entities = {}
|
|
for nasa in self.config.NASA_REPO:
|
|
namenorm = self._normalize_name(nasa)
|
|
sensor_type = self._get_sensor_type(nasa)
|
|
entities[namenorm] = {"platform": sensor_type}
|
|
|
|
device = {
|
|
"device": self._get_device(),
|
|
"origin": self._get_origin(),
|
|
"components": entities,
|
|
"qos": 2
|
|
}
|
|
|
|
logger.debug(f"Auto Discovery HomeAssistant Clear Message: ")
|
|
logger.debug(f"{device}")
|
|
|
|
self._publish(f"{self.config.MQTT['homeAssistantAutoDiscoverTopic']}/device/{self.DEVICE_ID}/config",
|
|
json.dumps(device, ensure_ascii=False),
|
|
qos=2,
|
|
retain=True)
|
|
|
|
def auto_discover_hass(self, name):
|
|
"""
|
|
Automatically discovers and configures Home Assistant entities based on the NASA_REPO configuration.
|
|
This function iterates through the NASA_REPO configuration to create and configure entities for Home Assistant.
|
|
It determines the type of sensor (binary_sensor or sensor) based on the configuration and sets various attributes
|
|
such as unit of measurement, device class, state class, and payloads for binary sensors. It then constructs a device
|
|
configuration payload and publishes it to the Home Assistant MQTT discovery topic.
|
|
The function performs the following steps:
|
|
1. Iterates through the NASA_REPO configuration.
|
|
2. Normalizes the name of each NASA_REPO entry.
|
|
3. Determines the sensor type (binary_sensor or sensor) based on the 'enum' values.
|
|
4. Configures the entity attributes such as unit of measurement, device class, state class, and payloads.
|
|
5. Constructs a device configuration payload.
|
|
6. Publishes the device configuration to the Home Assistant MQTT discovery topic.
|
|
Attributes:
|
|
entities (dict): A dictionary to store the configured entities.
|
|
device (dict): A dictionary to store the device configuration payload.
|
|
Logs:
|
|
Logs the constructed device configuration payload for debugging purposes.
|
|
Publishes:
|
|
Publishes the device configuration payload to the Home Assistant MQTT discovery topic with QoS 2 and retain flag set to True.
|
|
"""
|
|
entity = {}
|
|
namenorm = self._normalize_name(name)
|
|
sensor_type = self._get_sensor_type(name)
|
|
entity = {
|
|
"name": f"{namenorm}",""
|
|
"object_id": f"{self.DEVICE_ID}_{namenorm.lower()}",
|
|
"unique_id": f"{self.DEVICE_ID}_{name.lower()}",
|
|
"platform": sensor_type,
|
|
#"expire_after": 86400, # 1 day (24h * 60m * 60s)
|
|
"value_template": "{{ value }}",
|
|
#"value_template": "{{ value if value | length > 0 else 'unavailable' }}",
|
|
"state_topic": f"{self.config.MQTT['homeAssistantAutoDiscoverTopic']}/{sensor_type}/{self.DEVICE_ID}_{namenorm.lower()}/state",
|
|
}
|
|
|
|
if sensor_type == "sensor":
|
|
if len(self.config.NASA_REPO[name]['unit']) > 0:
|
|
entity['unit_of_measurement'] = self.config.NASA_REPO[name]['unit']
|
|
if entity['unit_of_measurement'] == "\u00b0C":
|
|
entity['device_class'] = "temperature"
|
|
elif entity['unit_of_measurement'] == '%':
|
|
entity['state_class'] = "measurement"
|
|
elif entity['unit_of_measurement'] == 'kW':
|
|
entity['device_class'] = "power"
|
|
elif entity['unit_of_measurement'] == 'rpm':
|
|
entity['state_class'] = "measurement"
|
|
elif entity['unit_of_measurement'] == 'bar':
|
|
entity['device_class'] = "pressure"
|
|
elif entity['unit_of_measurement'] == 'HP':
|
|
entity['device_class'] = "power"
|
|
elif entity['unit_of_measurement'] == 'hz':
|
|
entity['device_class'] = "frequency"
|
|
else:
|
|
entity['device_class'] = None
|
|
else:
|
|
entity['payload_on'] = "ON"
|
|
entity['payload_off'] = "OFF"
|
|
|
|
if 'state_class' in self.config.NASA_REPO[name]:
|
|
entity['state_class'] = self.config.NASA_REPO[name]['state_class']
|
|
|
|
if 'device_class' in self.config.NASA_REPO[name]:
|
|
entity['device_class'] = self.config.NASA_REPO[name]['device_class']
|
|
|
|
device = {
|
|
"device": self._get_device(),
|
|
"origin": self._get_origin(),
|
|
"qos": 2
|
|
}
|
|
device.update(entity)
|
|
|
|
logger.debug(f"Auto Discovery HomeAssistant Message: ")
|
|
logger.debug(f"{device}")
|
|
|
|
self._publish(f"{self.config.MQTT['homeAssistantAutoDiscoverTopic']}/{sensor_type}/{self.DEVICE_ID}_{name.lower()}/config",
|
|
json.dumps(device, ensure_ascii=False),
|
|
qos=2,
|
|
retain=True)
|
|
|
|
def _get_device(self):
|
|
return {
|
|
"identifiers": self.DEVICE_ID,
|
|
"name": "Samsung EHS",
|
|
"manufacturer": "Samsung",
|
|
"model": "Mono HQ Quiet",
|
|
"sw_version": "1.0.0"
|
|
}
|
|
|
|
def _get_origin(self):
|
|
return {
|
|
"name": "EHS-Sentinel",
|
|
"support_url": "https://github.com/echoDaveD/EHS-Sentinel"
|
|
}
|
|
|
|
def _normalize_name(self, name):
|
|
"""
|
|
Normalize the given name based on the specified naming convention.
|
|
If `useCamelCaseTopicNames` is True, the function will:
|
|
- Remove any of the following prefixes from the name: 'ENUM_', 'LVAR_', 'NASA_', 'VAR_'.
|
|
- Convert the name to CamelCase format.
|
|
If `useCamelCaseTopicNames` is False, the function will return the name as is.
|
|
Args:
|
|
name (str): The name to be normalized.
|
|
Returns:
|
|
str: The normalized name.
|
|
"""
|
|
|
|
if self.useCamelCaseTopicNames:
|
|
prefix_to_remove = ['ENUM_', 'LVAR_', 'NASA_', 'VAR_']
|
|
# remove unnecessary prefixes of name
|
|
for prefix in prefix_to_remove:
|
|
if name.startswith(prefix):
|
|
name = name[len(prefix):]
|
|
break
|
|
|
|
name_parts = name.split("_")
|
|
tmpname = name_parts[0].lower()
|
|
# construct new name in CamelCase
|
|
for i in range(1, len(name_parts)):
|
|
tmpname += name_parts[i].capitalize()
|
|
else:
|
|
tmpname = name
|
|
|
|
return tmpname
|
|
|
|
def _get_sensor_type(self, name):
|
|
"""
|
|
return the sensor type of given measurement
|
|
Args:
|
|
name (str): The name of the measurement.
|
|
Returns:
|
|
str: The sensor type: sensor or binary_sensor.
|
|
"""
|
|
sensor_type = "sensor"
|
|
if 'enum' in self.config.NASA_REPO[name]:
|
|
enum = [*self.config.NASA_REPO[name]['enum'].values()]
|
|
if all([en.lower() in ['on', 'off'] for en in enum]):
|
|
sensor_type = "binary_sensor"
|
|
|
|
return sensor_type
|