Feature/v1.0.0 release (#12)
v1.0.0 - 2025-03-13 EHS-Sentinel has been heavily modified to incorporate the control mechanism The read-in behavior of the modbus registers has been revised from chunks to single byte MessageProcessor now runs asynchronously MessageProducer added which takes over the writing communication with the WP Configuration of HASS entities has moved from hardcoded to NASA Repository NASA Repository has been fundamentally changed All FSV Values, NASA_POWER, VAR_IN_TEMP_WATER_LAW_TARGET_F, NASA_INDOOR_OPMODE are allowed for writing mode NASA_OUTDOOR_DEFROST_STEP DEFROST STEP 10 (b'0xa') added ENUM_IN_SG_READY_MODE_STATE ACTIVE (b'0x2') added New configuration point allowControl to allow control of the Samsung EHS heat pump (deactivated by default). [!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. new configuration points in logging controlMessage (default False) to print out the controlled mesagges invalidPacket (default False) prints out invalid messages (length not ok, x34 not at end...) Dashboard template has been split, ressources/dashboard_readonly_template.yaml is for readonly mode and the ressources/dashboard_controlmode_template.yaml for control mode
1
.gitignore
vendored
@@ -174,3 +174,4 @@ prot.csv
|
||||
helpertils/serial.py
|
||||
helpertils/test.py
|
||||
helpertils/socker.py
|
||||
helpertils/messagesFound.txt
|
||||
|
||||
@@ -30,29 +30,10 @@ class IndentFormatter(logging.Formatter):
|
||||
|
||||
|
||||
def __init__( self, fmt=None, datefmt=None ):
|
||||
"""
|
||||
Initializes the CustomLogger instance.
|
||||
Args:
|
||||
fmt (str, optional): The format string for the log messages. Defaults to None.
|
||||
datefmt (str, optional): The format string for the date in log messages. Defaults to None.
|
||||
Attributes:
|
||||
baseline (int): The baseline stack depth when the logger is initialized.
|
||||
"""
|
||||
logging.Formatter.__init__(self, fmt, datefmt)
|
||||
self.baseline = len(inspect.stack())
|
||||
|
||||
def format( self, rec ):
|
||||
"""
|
||||
Formats the log record by adding indentation and function name.
|
||||
This method customizes the log record by adding an indentation level
|
||||
based on the current stack depth and includes the name of the function
|
||||
from which the log call was made. It then uses the base Formatter class
|
||||
to format the record and returns the formatted string.
|
||||
Args:
|
||||
rec (logging.LogRecord): The log record to be formatted.
|
||||
Returns:
|
||||
str: The formatted log record string.
|
||||
"""
|
||||
log_fmt = self.FORMATS.get(rec.levelno)
|
||||
formatter = logging.Formatter(log_fmt)
|
||||
|
||||
@@ -76,11 +57,5 @@ logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
def setDebugMode():
|
||||
"""
|
||||
Set the logging level to DEBUG and log a message indicating that debug mode is enabled.
|
||||
This function sets the logging level of the logger to DEBUG, which means that all messages
|
||||
at the DEBUG level and above will be logged. It also logs a debug message to indicate that
|
||||
debug mode has been activated.
|
||||
"""
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.debug("Debug mode is on...")
|
||||
|
||||
@@ -24,34 +24,12 @@ class EHSArguments:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""
|
||||
Create and return a new instance of the class, ensuring that only one instance exists (singleton pattern).
|
||||
This method overrides the default behavior of object creation to implement the singleton pattern.
|
||||
It checks if an instance of the class already exists; if not, it creates a new instance and marks it as uninitialized.
|
||||
Args:
|
||||
*args: Variable length argument list.
|
||||
**kwargs: Arbitrary keyword arguments.
|
||||
Returns:
|
||||
EHSArguments: The singleton instance of the EHSArguments class.
|
||||
"""
|
||||
|
||||
if not cls._instance:
|
||||
cls._instance = super(EHSArguments, cls).__new__(cls, *args, **kwargs)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initializes the EHSArguments class, parses command-line arguments, and sets up configuration.
|
||||
This method performs the following steps:
|
||||
1. Checks if the class has already been initialized to prevent re-initialization.
|
||||
2. Sets up an argument parser to handle command-line arguments.
|
||||
3. Parses the command-line arguments and validates them.
|
||||
4. Checks if the specified config file and dump file exist.
|
||||
5. Sets the class attributes based on the parsed arguments.
|
||||
Raises:
|
||||
ArgumentException: If the required arguments are not provided or if the specified files do not exist.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
69
EHSConfig.py
@@ -11,15 +11,6 @@ class EHSConfig():
|
||||
Singleton class to handle the configuration for the EHS Sentinel application.
|
||||
This class reads configuration parameters from a YAML file and validates them.
|
||||
It ensures that only one instance of the configuration exists throughout the application.
|
||||
Attributes:
|
||||
MQTT (dict): Configuration parameters for MQTT.
|
||||
GENERAL (dict): General configuration parameters.
|
||||
SERIAL (dict): Configuration parameters for serial communication.
|
||||
NASA_REPO (dict): Configuration parameters for NASA repository.
|
||||
Methods:
|
||||
__new__(cls, *args, **kwargs): Ensures only one instance of the class is created.
|
||||
__init__(self, *args, **kwargs): Initializes the configuration by reading and validating the YAML file.
|
||||
validate(self): Validates the configuration parameters.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
@@ -30,44 +21,15 @@ class EHSConfig():
|
||||
NASA_REPO = None
|
||||
LOGGING = {}
|
||||
POLLING = None
|
||||
NASA_VAL_STORE = {}
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""
|
||||
Create a new instance of the EHSConfig class if one does not already exist.
|
||||
This method ensures that only one instance of the EHSConfig class is created
|
||||
(singleton pattern). If an instance already exists, it returns the existing instance.
|
||||
Args:
|
||||
cls: The class being instantiated.
|
||||
*args: Variable length argument list.
|
||||
**kwargs: Arbitrary keyword arguments.
|
||||
Returns:
|
||||
EHSConfig: The single instance of the EHSConfig class.
|
||||
"""
|
||||
|
||||
if not cls._instance:
|
||||
cls._instance = super(EHSConfig, cls).__new__(cls, *args, **kwargs)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Initialize the EHSConfig instance.
|
||||
This method initializes the EHSConfig instance by loading configuration
|
||||
settings from a YAML file specified in the EHSArguments. It ensures that
|
||||
the initialization process is only performed once by checking the
|
||||
_initialized attribute. If the instance is already initialized, the method
|
||||
returns immediately. Otherwise, it proceeds to load the configuration and
|
||||
validate it.
|
||||
Args:
|
||||
*args: Variable length argument list.
|
||||
**kwargs: Arbitrary keyword arguments.
|
||||
Attributes:
|
||||
args (EHSArguments): An instance of EHSArguments containing the
|
||||
configuration file path.
|
||||
MQTT (dict): MQTT configuration settings loaded from the YAML file.
|
||||
GENERAL (dict): General configuration settings loaded from the YAML file.
|
||||
SERIAL (dict): Serial configuration settings loaded from the YAML file.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
@@ -101,16 +63,6 @@ class EHSConfig():
|
||||
self.validate()
|
||||
|
||||
def parse_time_string(self, time_str: str) -> int:
|
||||
"""
|
||||
Parses a time string like '10m' or '10s' and converts it to seconds.
|
||||
|
||||
Supported formats:
|
||||
- '10m' for 10 minutes
|
||||
- '10s' for 10 seconds
|
||||
|
||||
Returns:
|
||||
- Equivalent time in seconds as an integer.
|
||||
"""
|
||||
match = re.match(r'^(\d+)([smh])$', time_str.strip(), re.IGNORECASE)
|
||||
if not match:
|
||||
raise ValueError("Invalid time format. Use '10s', '10m', or '10h'.")
|
||||
@@ -126,15 +78,6 @@ class EHSConfig():
|
||||
return value * conversion_factors[unit]
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
Validates the configuration parameters for the EHS Sentinel application.
|
||||
This method checks the presence and validity of various configuration parameters
|
||||
such as NASA repository file, serial device, baudrate, MQTT broker URL, broker port,
|
||||
and MQTT credentials. It raises a ConfigException if any required parameter is missing
|
||||
or invalid. Additionally, it sets default values for optional parameters if they are not provided.
|
||||
Raises:
|
||||
ConfigException: If any required configuration parameter is missing or invalid.
|
||||
"""
|
||||
if os.path.isfile(self.GENERAL['nasaRepositoryFile']):
|
||||
with open(self.GENERAL['nasaRepositoryFile'], mode='r') as file:
|
||||
self.NASA_REPO = yaml.safe_load(file)
|
||||
@@ -144,6 +87,9 @@ class EHSConfig():
|
||||
if 'protocolFile' not in self.GENERAL:
|
||||
self.GENERAL['protocolFile'] = None
|
||||
|
||||
if 'allowControl' not in self.GENERAL:
|
||||
self.GENERAL['allowControl'] = False
|
||||
|
||||
if self.SERIAL is None and self.TCP is None:
|
||||
raise ConfigException(argument="", message="define tcp or serial config parms")
|
||||
|
||||
@@ -213,8 +159,8 @@ class EHSConfig():
|
||||
if 'messageNotFound' not in self.LOGGING:
|
||||
self.LOGGING['messageNotFound'] = False
|
||||
|
||||
if 'messageNotFound' not in self.LOGGING:
|
||||
self.LOGGING['messageNotFound'] = False
|
||||
if 'invalidPacket' not in self.LOGGING:
|
||||
self.LOGGING['invalidPacket'] = False
|
||||
|
||||
if 'deviceAdded' not in self.LOGGING:
|
||||
self.LOGGING['deviceAdded'] = True
|
||||
@@ -228,6 +174,9 @@ class EHSConfig():
|
||||
if 'pollerMessage' not in self.LOGGING:
|
||||
self.LOGGING['pollerMessage'] = False
|
||||
|
||||
if 'controlMessage' not in self.LOGGING:
|
||||
self.LOGGING['controlMessage'] = False
|
||||
|
||||
logger.info(f"Logging Config:")
|
||||
for key, value in self.LOGGING.items():
|
||||
logger.info(f" {key}: {value}")
|
||||
|
||||
290
MQTTClient.py
@@ -10,6 +10,7 @@ import gmqtt
|
||||
from CustomLogger import logger
|
||||
from EHSArguments import EHSArguments
|
||||
from EHSConfig import EHSConfig
|
||||
from MessageProducer import MessageProducer
|
||||
|
||||
class MQTTClient:
|
||||
"""
|
||||
@@ -23,18 +24,6 @@ class MQTTClient:
|
||||
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)
|
||||
@@ -42,32 +31,12 @@ class MQTTClient:
|
||||
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.message_producer = None
|
||||
self._initialized = True
|
||||
self.broker = self.config.MQTT['broker-url']
|
||||
self.port = self.config.MQTT['broker-port']
|
||||
@@ -88,17 +57,6 @@ class MQTTClient:
|
||||
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)
|
||||
|
||||
@@ -106,53 +64,21 @@ class MQTTClient:
|
||||
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.
|
||||
"""
|
||||
|
||||
def subscribe_known_topics(self):
|
||||
logger.info("Subscribe to known devices topic")
|
||||
self.client.subscribe(
|
||||
[
|
||||
sublist = [
|
||||
gmqtt.Subscription(f"{self.topicPrefix.replace('/', '')}/{self.known_devices_topic}", 1),
|
||||
gmqtt.Subscription(f"{self.homeAssistantAutoDiscoverTopic}/status", 1)
|
||||
]
|
||||
)
|
||||
if self.config.GENERAL['allowControl']:
|
||||
sublist.append(gmqtt.Subscription(f"{self.topicPrefix.replace('/', '')}/entity/+/set", 1))
|
||||
|
||||
self.client.subscribe(sublist)
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def on_message(self, client, topic, payload, qos, properties):
|
||||
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(",")]))
|
||||
@@ -174,19 +100,15 @@ class MQTTClient:
|
||||
logger.info("Known Devices Topic have been cleared")
|
||||
self.clear_hass()
|
||||
logger.info("All configuration from HASS has been resetet")
|
||||
|
||||
if topic.startswith(f"{self.topicPrefix.replace('/', '')}/entity"):
|
||||
logger.info(f"HASS Set Entity Messages {topic} received: {payload.decode()}")
|
||||
parts = topic.split("/")
|
||||
if self.message_producer is None:
|
||||
self.message_producer = MessageProducer(None)
|
||||
asyncio.create_task(self.message_producer.write_request(parts[2], payload.decode(), read_request_after=True))
|
||||
|
||||
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:
|
||||
@@ -194,18 +116,7 @@ class MQTTClient:
|
||||
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.
|
||||
"""
|
||||
|
||||
def on_disconnect(self, client, packet, exc=None):
|
||||
logger.info(f"Disconnected with result code ")
|
||||
logger.warning("Unexpected disconnection. Reconnecting...")
|
||||
while True:
|
||||
@@ -216,31 +127,12 @@ class MQTTClient:
|
||||
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
|
||||
"""
|
||||
|
||||
def _publish(self, topic, payload, qos=0, retain=False):
|
||||
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} ")
|
||||
@@ -248,20 +140,7 @@ class MQTTClient:
|
||||
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.
|
||||
"""
|
||||
|
||||
async def publish_message(self, name, value):
|
||||
newname = f"{self._normalize_name(name)}"
|
||||
|
||||
if len(self.homeAssistantAutoDiscoverTopic) > 0:
|
||||
@@ -270,13 +149,10 @@ class MQTTClient:
|
||||
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"
|
||||
if self.config.NASA_REPO[name]['hass_opts']['writable']:
|
||||
sensor_type = self.config.NASA_REPO[name]['hass_opts']['platform']['type']
|
||||
else:
|
||||
sensor_type = self.config.NASA_REPO[name]['hass_opts']['default_platform']
|
||||
topicname = f"{self.config.MQTT['homeAssistantAutoDiscoverTopic']}/{sensor_type}/{self.DEVICE_ID}_{newname.lower()}/state"
|
||||
else:
|
||||
topicname = f"{self.topicPrefix.replace('/', '')}/{newname}"
|
||||
@@ -287,13 +163,13 @@ class MQTTClient:
|
||||
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)
|
||||
if self.config.NASA_REPO[nasa]['hass_opts']['writable']:
|
||||
sensor_type = self.config.NASA_REPO[nasa]['hass_opts']['platform']['type']
|
||||
else:
|
||||
sensor_type = self.config.NASA_REPO[nasa]['hass_opts']['default_platform']
|
||||
entities[namenorm] = {"platform": sensor_type}
|
||||
|
||||
device = {
|
||||
@@ -312,69 +188,47 @@ class MQTTClient:
|
||||
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}",""
|
||||
"name": f"{namenorm}",
|
||||
"object_id": f"{self.DEVICE_ID}_{namenorm.lower()}",
|
||||
"unique_id": f"{self.DEVICE_ID}_{name.lower()}",
|
||||
"platform": sensor_type,
|
||||
"force_update": True,
|
||||
#"expire_after": 86400, # 1 day (24h * 60m * 60s)
|
||||
"value_template": "{{ value }}",
|
||||
"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
|
||||
if self.config.NASA_REPO[name]['hass_opts']['writable'] and self.config.GENERAL['allowControl']:
|
||||
sensor_type = self.config.NASA_REPO[name]['hass_opts']['platform']['type']
|
||||
if sensor_type == 'select':
|
||||
entity['options'] = self.config.NASA_REPO[name]['hass_opts']['platform']['options']
|
||||
if sensor_type == 'number':
|
||||
entity['mode'] = self.config.NASA_REPO[name]['hass_opts']['platform']['mode']
|
||||
entity['min'] = self.config.NASA_REPO[name]['hass_opts']['platform']['min']
|
||||
entity['max'] = self.config.NASA_REPO[name]['hass_opts']['platform']['max']
|
||||
if 'step' in self.config.NASA_REPO[name]['hass_opts']['platform']:
|
||||
entity['step'] = self.config.NASA_REPO[name]['hass_opts']['platform']['step']
|
||||
|
||||
entity['command_topic'] = f"{self.topicPrefix.replace('/', '')}/entity/{name}/set"
|
||||
entity['optimistic'] = False
|
||||
else:
|
||||
entity['payload_on'] = "ON"
|
||||
entity['payload_off'] = "OFF"
|
||||
sensor_type = self.config.NASA_REPO[name]['hass_opts']['default_platform']
|
||||
|
||||
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']
|
||||
if 'unit' in self.config.NASA_REPO[name]['hass_opts']:
|
||||
entity['unit_of_measurement'] = self.config.NASA_REPO[name]['hass_opts']['unit']
|
||||
|
||||
entity['platform'] = sensor_type
|
||||
entity['state_topic'] = f"{self.config.MQTT['homeAssistantAutoDiscoverTopic']}/{sensor_type}/{self.DEVICE_ID}_{namenorm.lower()}/state"
|
||||
|
||||
if 'payload_off' in self.config.NASA_REPO[name]['hass_opts']['platform']:
|
||||
entity['payload_off'] = "OFF"
|
||||
if 'payload_on' in self.config.NASA_REPO[name]['hass_opts']['platform']:
|
||||
entity['payload_on'] = "ON"
|
||||
if 'state_class' in self.config.NASA_REPO[name]['hass_opts']:
|
||||
entity['state_class'] = self.config.NASA_REPO[name]['hass_opts']['state_class']
|
||||
if 'device_class' in self.config.NASA_REPO[name]['hass_opts']:
|
||||
entity['device_class'] = self.config.NASA_REPO[name]['hass_opts']['device_class']
|
||||
|
||||
device = {
|
||||
"device": self._get_device(),
|
||||
@@ -407,18 +261,6 @@ class MQTTClient:
|
||||
}
|
||||
|
||||
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
|
||||
@@ -436,19 +278,3 @@ class MQTTClient:
|
||||
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
|
||||
|
||||
@@ -14,70 +14,27 @@ from NASAPacket import NASAPacket
|
||||
class MessageProcessor:
|
||||
"""
|
||||
The MessageProcessor class is responsible for handling and processing incoming messages for the EHS-Sentinel system.
|
||||
It follows the singleton pattern to ensure only one instance is created. The class provides methods to process
|
||||
messages, extract submessages, search for message definitions in a configuration repository, and determine the
|
||||
value of message payloads based on predefined rules. It also includes logging for debugging and tracing the
|
||||
The class provides methods to process messages, extract submessages, search for message definitions in a configuration repository,
|
||||
and determine the value of message payloads based on predefined rules. It also includes logging for debugging and tracing the
|
||||
message processing steps.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
tmpdict = {}
|
||||
|
||||
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.
|
||||
Args:
|
||||
cls (type): The class being instantiated.
|
||||
*args: Variable length argument list.
|
||||
**kwargs: Arbitrary keyword arguments.
|
||||
Returns:
|
||||
MessageProcessor: The single instance of the MessageProcessor class.
|
||||
"""
|
||||
if not cls._instance:
|
||||
cls._instance = super(MessageProcessor, cls).__new__(cls, *args, **kwargs)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initializes the MessageProcessor instance.
|
||||
This constructor checks if the instance has already been initialized to prevent reinitialization.
|
||||
If not initialized, it sets the _initialized flag to True, logs the initialization process,
|
||||
and initializes the configuration and argument handling components.
|
||||
Attributes:
|
||||
_initialized (bool): A flag indicating whether the instance has been initialized.
|
||||
config (EHSConfig): An instance of the EHSConfig class for configuration management.
|
||||
args (EHSArguments): An instance of the EHSArguments class for argument handling.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self.config = EHSConfig()
|
||||
self.args = EHSArguments()
|
||||
self.mqtt = MQTTClient()
|
||||
self.NASA_VAL_STORE = {}
|
||||
|
||||
def process_message(self, packet: NASAPacket):
|
||||
"""
|
||||
Processes an incoming packet .
|
||||
Args:
|
||||
message (list): A list of integers representing the message bytes.
|
||||
Raises:
|
||||
MessageWarningException: If the message is invalid due to missing end byte, incorrect length, or other processing errors.
|
||||
Logs:
|
||||
Various debug and info logs to trace the processing steps, including packet size, raw and hex message content, source address, capacity, and extracted message details.
|
||||
"""
|
||||
async def process_message(self, packet: NASAPacket):
|
||||
for msg in packet.packet_messages:
|
||||
hexmsg = hex(msg.packet_message)
|
||||
hexmsg = f"0x{msg.packet_message:04x}" #hex(msg.packet_message)
|
||||
msgname = self.search_nasa_table(hexmsg)
|
||||
if msgname is not None:
|
||||
try:
|
||||
msgvalue = self.determine_value(msg.packet_payload, msgname)
|
||||
msgvalue = self.determine_value(msg.packet_payload, msgname, msg.packet_message_type)
|
||||
except Exception as e:
|
||||
raise MessageWarningException(argument=f"{msg.packet_payload}/{[hex(x) for x in msg.packet_payload]}", message=f"Value of {hexmsg} couldn't be determinate, skip Message {e}")
|
||||
self.protocolMessage(msg, msgname, msgvalue)
|
||||
await self.protocolMessage(msg, msgname, msgvalue)
|
||||
else:
|
||||
packedval = int.from_bytes(msg.packet_payload, byteorder='big', signed=True)
|
||||
if self.config.LOGGING['messageNotFound']:
|
||||
@@ -85,25 +42,10 @@ class MessageProcessor:
|
||||
else:
|
||||
logger.debug(f"Message not Found in NASA repository: {hexmsg:<6} Type: {msg.packet_message_type} Payload: {msg.packet_payload} = {packedval}")
|
||||
|
||||
def protocolMessage(self, msg: NASAMessage, msgname, msgvalue):
|
||||
"""
|
||||
Processes a protocol message by logging, writing to a protocol file, publishing via MQTT,
|
||||
and updating internal value store. Additionally, it calculates and processes specific
|
||||
derived values based on certain message names.
|
||||
Args:
|
||||
msg (NASAMessage): The NASA message object containing packet information.
|
||||
msgname (str): The name of the message.
|
||||
msgvalue (Any): The value of the message.
|
||||
Side Effects:
|
||||
- Logs the message details.
|
||||
- Appends the message details to a protocol file if configured.
|
||||
- Publishes the message via MQTT.
|
||||
- Updates the internal NASA value store with the message value.
|
||||
- Calculates and processes derived values for specific message names.
|
||||
"""
|
||||
async def protocolMessage(self, msg: NASAMessage, msgname, msgvalue):
|
||||
|
||||
if self.config.LOGGING['proccessedMessage']:
|
||||
logger.info(f"Message number: {hex(msg.packet_message):<6} {msgname:<50} Type: {msg.packet_message_type} Payload: {msgvalue}")
|
||||
logger.info(f"Message number: {hex(msg.packet_message):<6} {msgname:<50} Type: {msg.packet_message_type} Payload: {msgvalue} ({msg.packet_payload})")
|
||||
else:
|
||||
logger.debug(f"Message number: {hex(msg.packet_message):<6} {msgname:<50} Type: {msg.packet_message_type} Payload: {msgvalue}")
|
||||
|
||||
@@ -111,87 +53,94 @@ class MessageProcessor:
|
||||
with open(self.config.GENERAL['protocolFile'], "a") as protWriter:
|
||||
protWriter.write(f"{hex(msg.packet_message):<6},{msg.packet_message_type},{msgname:<50},{msgvalue}\n")
|
||||
|
||||
self.mqtt.publish_message(msgname, msgvalue)
|
||||
await self.mqtt.publish_message(msgname, msgvalue)
|
||||
|
||||
self.NASA_VAL_STORE[msgname] = msgvalue
|
||||
self.config.NASA_VAL_STORE[msgname] = msgvalue
|
||||
|
||||
if msgname in ['NASA_OUTDOOR_TW2_TEMP', 'NASA_OUTDOOR_TW1_TEMP', 'VAR_IN_FLOW_SENSOR_CALC']:
|
||||
if all(k in self.NASA_VAL_STORE for k in ['NASA_OUTDOOR_TW2_TEMP', 'NASA_OUTDOOR_TW1_TEMP', 'VAR_IN_FLOW_SENSOR_CALC']):
|
||||
if all(k in self.config.NASA_VAL_STORE for k in ['NASA_OUTDOOR_TW2_TEMP', 'NASA_OUTDOOR_TW1_TEMP', 'VAR_IN_FLOW_SENSOR_CALC']):
|
||||
value = round(
|
||||
abs(
|
||||
(self.NASA_VAL_STORE['NASA_OUTDOOR_TW2_TEMP'] - self.NASA_VAL_STORE['NASA_OUTDOOR_TW1_TEMP']) *
|
||||
(self.NASA_VAL_STORE['VAR_IN_FLOW_SENSOR_CALC']/60)
|
||||
(self.config.NASA_VAL_STORE['NASA_OUTDOOR_TW2_TEMP'] - self.config.NASA_VAL_STORE['NASA_OUTDOOR_TW1_TEMP']) *
|
||||
(self.config.NASA_VAL_STORE['VAR_IN_FLOW_SENSOR_CALC']/60)
|
||||
* 4190
|
||||
) , 4
|
||||
)
|
||||
if (value < 15000 and value > 0): # only if heater output between 0 und 15000 W
|
||||
self.protocolMessage(NASAMessage(packet_message=0x9999, packet_message_type=1),
|
||||
await self.protocolMessage(NASAMessage(packet_message=0x9999, packet_message_type=1, packet_payload=[0]),
|
||||
"NASA_EHSSENTINEL_HEAT_OUTPUT",
|
||||
value
|
||||
)
|
||||
|
||||
if msgname in ('NASA_EHSSENTINEL_HEAT_OUTPUT', 'NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT'):
|
||||
if all(k in self.NASA_VAL_STORE for k in ['NASA_EHSSENTINEL_HEAT_OUTPUT', 'NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT']):
|
||||
if (self.NASA_VAL_STORE['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT'] > 0):
|
||||
value = round((self.NASA_VAL_STORE['NASA_EHSSENTINEL_HEAT_OUTPUT'] / self.NASA_VAL_STORE['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT']/1000.), 3)
|
||||
if all(k in self.config.NASA_VAL_STORE for k in ['NASA_EHSSENTINEL_HEAT_OUTPUT', 'NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT']):
|
||||
if (self.config.NASA_VAL_STORE['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT'] > 0):
|
||||
value = round((self.config.NASA_VAL_STORE['NASA_EHSSENTINEL_HEAT_OUTPUT'] / self.config.NASA_VAL_STORE['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT']/1000.), 3)
|
||||
if (value < 20 and value > 0):
|
||||
self.protocolMessage(NASAMessage(packet_message=0x9998, packet_message_type=1),
|
||||
await self.protocolMessage(NASAMessage(packet_message=0x9998, packet_message_type=1, packet_payload=[0]),
|
||||
"NASA_EHSSENTINEL_COP",
|
||||
value
|
||||
)
|
||||
|
||||
if msgname in ('NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT_ACCUM', 'LVAR_IN_TOTAL_GENERATED_POWER'):
|
||||
if all(k in self.NASA_VAL_STORE for k in ['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT_ACCUM', 'LVAR_IN_TOTAL_GENERATED_POWER']):
|
||||
if (self.NASA_VAL_STORE['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT_ACCUM'] > 0):
|
||||
value = round(self.NASA_VAL_STORE['LVAR_IN_TOTAL_GENERATED_POWER'] / self.NASA_VAL_STORE['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT_ACCUM'], 3)
|
||||
if all(k in self.config.NASA_VAL_STORE for k in ['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT_ACCUM', 'LVAR_IN_TOTAL_GENERATED_POWER']):
|
||||
if (self.config.NASA_VAL_STORE['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT_ACCUM'] > 0):
|
||||
value = round(self.config.NASA_VAL_STORE['LVAR_IN_TOTAL_GENERATED_POWER'] / self.config.NASA_VAL_STORE['NASA_OUTDOOR_CONTROL_WATTMETER_ALL_UNIT_ACCUM'], 3)
|
||||
|
||||
if (value < 20 and value > 0):
|
||||
self.protocolMessage(NASAMessage(packet_message=0x9997, packet_message_type=1),
|
||||
await self.protocolMessage(NASAMessage(packet_message=0x9997, packet_message_type=1, packet_payload=[0]),
|
||||
"NASA_EHSSENTINEL_TOTAL_COP",
|
||||
value
|
||||
)
|
||||
|
||||
def search_nasa_table(self, address):
|
||||
"""
|
||||
Searches for a specific address in the NASA_REPO configuration and returns the corresponding key.
|
||||
Args:
|
||||
address (str): The address to search for in the NASA_REPO.
|
||||
Returns:
|
||||
str: The key associated with the given address if found, otherwise None.
|
||||
"""
|
||||
for key, value in self.config.NASA_REPO.items():
|
||||
if value['address'].lower() == address:
|
||||
return key
|
||||
|
||||
def determine_value(self, rawvalue, msgname):
|
||||
"""
|
||||
Determines the processed value from a raw byte input based on the message name configuration.
|
||||
Args:
|
||||
rawvalue (bytes): The raw byte value to be processed.
|
||||
msgname (str): The name of the message which determines the processing rules.
|
||||
Returns:
|
||||
float or str: The processed value, which could be a numerical value or an enumerated string.
|
||||
Raises:
|
||||
Warning: Logs a warning if the arithmetic function cannot be applied and uses the raw value instead.
|
||||
"""
|
||||
arithmetic = self.config.NASA_REPO[msgname]['arithmetic'].replace("value", 'packed_value')
|
||||
|
||||
def is_valid_rawvalue(self, rawvalue: bytes) -> bool:
|
||||
return all(0x20 <= b <= 0x7E or b in (0x00, 0xFF) for b in rawvalue)
|
||||
|
||||
packed_value = int.from_bytes(rawvalue, byteorder='big', signed=True)
|
||||
def determine_value(self, rawvalue, msgname, packet_message_type):
|
||||
if packet_message_type == 3:
|
||||
value = ""
|
||||
|
||||
if len(arithmetic) > 0:
|
||||
try:
|
||||
value = eval(arithmetic)
|
||||
except Exception as e:
|
||||
logger.warning(f"Arithmetic Function couldn't been applied for Message {msgname}, using raw value: arithmetic = {arithmetic} {e}")
|
||||
value = packed_value
|
||||
else:
|
||||
value = packed_value
|
||||
|
||||
if self.config.NASA_REPO[msgname]['type'] == 'ENUM':
|
||||
if 'enum' in self.config.NASA_REPO[msgname]:
|
||||
value = self.config.NASA_REPO[msgname]['enum'][int.from_bytes(rawvalue, byteorder='big')].upper()
|
||||
if self.is_valid_rawvalue(rawvalue[1:-1]):
|
||||
for byte in rawvalue[1:-1]:
|
||||
if byte != 0x00 and byte != 0xFF:
|
||||
char = chr(byte) if 32 <= byte <= 126 else f"{byte}"
|
||||
value += char
|
||||
else:
|
||||
value += " "
|
||||
value = value.strip()
|
||||
else:
|
||||
value = f"Unknown enum value: {value}"
|
||||
value = "".join([f"{int(x)}" for x in rawvalue])
|
||||
|
||||
#logger.info(f"{msgname} Structure: {rawvalue} type of {value}")
|
||||
else:
|
||||
if 'arithmetic' in self.config.NASA_REPO[msgname]:
|
||||
arithmetic = self.config.NASA_REPO[msgname]['arithmetic'].replace("value", 'packed_value')
|
||||
else:
|
||||
arithmetic = ''
|
||||
|
||||
packed_value = int.from_bytes(rawvalue, byteorder='big', signed=True)
|
||||
|
||||
if len(arithmetic) > 0:
|
||||
try:
|
||||
value = eval(arithmetic)
|
||||
except Exception as e:
|
||||
logger.warning(f"Arithmetic Function couldn't been applied for Message {msgname}, using raw value: arithmetic = {arithmetic} {e} {packed_value} {rawvalue}")
|
||||
value = packed_value
|
||||
else:
|
||||
value = packed_value
|
||||
|
||||
value = round(value, 3)
|
||||
|
||||
if 'type' in self.config.NASA_REPO[msgname]:
|
||||
if self.config.NASA_REPO[msgname]['type'] == 'ENUM':
|
||||
if 'enum' in self.config.NASA_REPO[msgname]:
|
||||
value = self.config.NASA_REPO[msgname]['enum'][int.from_bytes(rawvalue, byteorder='big')]
|
||||
else:
|
||||
value = f"Unknown enum value: {value}"
|
||||
|
||||
return value
|
||||
|
||||
152
MessageProducer.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from CustomLogger import logger
|
||||
from EHSArguments import EHSArguments
|
||||
from EHSConfig import EHSConfig
|
||||
from EHSExceptions import MessageWarningException
|
||||
import asyncio
|
||||
|
||||
from NASAMessage import NASAMessage
|
||||
from NASAPacket import NASAPacket, AddressClassEnum, PacketType, DataType
|
||||
|
||||
class MessageProducer:
|
||||
"""
|
||||
The MessageProducer class is responsible for sending messages to the EHS-Sentinel system.
|
||||
It follows the singleton pattern to ensure only one instance is created. The class provides methods to request and write
|
||||
messages and transforme the value of message payloads based on predefined rules. It also includes logging for debugging and tracing the
|
||||
message producing steps.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_CHUNKSIZE = 10 # message requests list will be split into this chunks, experience have shown that more then 10 are too much for an packet
|
||||
writer = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if not cls._instance:
|
||||
cls._instance = super(MessageProducer, cls).__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, writer: asyncio.StreamWriter):
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self.writer = writer
|
||||
self.config = EHSConfig()
|
||||
|
||||
async def read_request(self, list_of_messages: list):
|
||||
chunks = [list_of_messages[i:i + self._CHUNKSIZE] for i in range(0, len(list_of_messages), self._CHUNKSIZE)]
|
||||
for chunk in chunks:
|
||||
await asyncio.sleep(0.5)
|
||||
nasa_packet = self._build_default_read_packet()
|
||||
nasa_packet.set_packet_messages([self._build_message(x) for x in chunk])
|
||||
await self._write_packet_to_serial(nasa_packet)
|
||||
|
||||
if self.config.LOGGING['pollerMessage']:
|
||||
logger.info(f"Polling following NASAPacket: {nasa_packet}")
|
||||
else:
|
||||
logger.debug(f"Sent data NASAPacket: {nasa_packet}")
|
||||
|
||||
async def write_request(self, message: str, value: str | int, read_request_after=False):
|
||||
nasa_packet = self._build_default_request_packet()
|
||||
nasa_packet.set_packet_messages([self._build_message(message.strip(), self._decode_value(message.strip(), value.strip()))])
|
||||
nasa_packet.to_raw()
|
||||
if self.config.LOGGING['controlMessage']:
|
||||
logger.info(f"Write request for {message} with value: {value}")
|
||||
logger.info(f"Sending NASA packet: {nasa_packet}")
|
||||
else:
|
||||
logger.debug(f"Write request for {message} with value: {value}")
|
||||
logger.debug(f"Sending NASA packet: {nasa_packet}")
|
||||
await self._write_packet_to_serial(nasa_packet)
|
||||
await asyncio.sleep(1)
|
||||
await self.read_request([message])
|
||||
|
||||
def _search_nasa_enumkey_for_value(self, message, value):
|
||||
if 'type' in self.config.NASA_REPO[message] and self.config.NASA_REPO[message]['type'] == 'ENUM':
|
||||
for key, val in self.config.NASA_REPO[message]['enum'].items():
|
||||
if val == value:
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
def is_number(self, s):
|
||||
return s.replace('+','',1).replace('-','',1).replace('.','',1).isdigit()
|
||||
|
||||
def _decode_value(self, message, value) -> int:
|
||||
enumval = self._search_nasa_enumkey_for_value(message, value)
|
||||
if enumval is None:
|
||||
if self.is_number(value):
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError as e:
|
||||
value = float(value)
|
||||
|
||||
if 'reverse-arithmetic' in self.config.NASA_REPO[message]:
|
||||
arithmetic = self.config.NASA_REPO[message]['reverse-arithmetic']
|
||||
else:
|
||||
arithmetic = ''
|
||||
if len(arithmetic) > 0:
|
||||
try:
|
||||
return int(eval(arithmetic))
|
||||
except Exception as e:
|
||||
logger.warning(f"Arithmetic Function couldn't been applied for Message {message}, using raw value: reverse-arithmetic = {arithmetic} {e} {value}")
|
||||
return value
|
||||
else:
|
||||
value = int(enumval)
|
||||
|
||||
return value
|
||||
|
||||
def _build_message(self, message, value=None) -> NASAMessage:
|
||||
tmpmsg = NASAMessage()
|
||||
tmpmsg.set_packet_message(self._extract_address(message))
|
||||
if value is None:
|
||||
value = 0
|
||||
if tmpmsg.packet_message_type == 0:
|
||||
value_raw = value.to_bytes(1, byteorder='big', signed=True)
|
||||
elif tmpmsg.packet_message_type == 1:
|
||||
value_raw = value.to_bytes(2, byteorder='big', signed=True)
|
||||
elif tmpmsg.packet_message_type == 2:
|
||||
value_raw = value.to_bytes(4, byteorder='big', signed=True)
|
||||
else:
|
||||
raise MessageWarningException(argument=tmpmsg.packet_message_type, message=f"Unknown Type for {message} type:")
|
||||
tmpmsg.set_packet_payload_raw(value_raw)
|
||||
return tmpmsg
|
||||
|
||||
def _extract_address(self, messagename) -> int:
|
||||
return int(self.config.NASA_REPO[messagename]['address'], 16)
|
||||
|
||||
def _build_default_read_packet(self) -> NASAPacket:
|
||||
nasa_msg = NASAPacket()
|
||||
nasa_msg.set_packet_source_address_class(AddressClassEnum.JIGTester)
|
||||
nasa_msg.set_packet_source_channel(255)
|
||||
nasa_msg.set_packet_source_address(0)
|
||||
nasa_msg.set_packet_dest_address_class(AddressClassEnum.BroadcastSetLayer)
|
||||
nasa_msg.set_packet_dest_channel(0)
|
||||
nasa_msg.set_packet_dest_address(32)
|
||||
nasa_msg.set_packet_information(True)
|
||||
nasa_msg.set_packet_version(2)
|
||||
nasa_msg.set_packet_retry_count(0)
|
||||
nasa_msg.set_packet_type(PacketType.Normal)
|
||||
nasa_msg.set_packet_data_type(DataType.Read)
|
||||
nasa_msg.set_packet_number(166)
|
||||
return nasa_msg
|
||||
|
||||
def _build_default_request_packet(self) -> NASAPacket:
|
||||
nasa_msg = NASAPacket()
|
||||
nasa_msg.set_packet_source_address_class(AddressClassEnum.JIGTester)
|
||||
nasa_msg.set_packet_source_channel(0)
|
||||
nasa_msg.set_packet_source_address(255)
|
||||
nasa_msg.set_packet_dest_address_class(AddressClassEnum.Indoor)
|
||||
nasa_msg.set_packet_dest_channel(0)
|
||||
nasa_msg.set_packet_dest_address(0)
|
||||
nasa_msg.set_packet_information(True)
|
||||
nasa_msg.set_packet_version(2)
|
||||
nasa_msg.set_packet_retry_count(0)
|
||||
nasa_msg.set_packet_type(PacketType.Normal)
|
||||
nasa_msg.set_packet_data_type(DataType.Request)
|
||||
nasa_msg.set_packet_number(166)
|
||||
return nasa_msg
|
||||
|
||||
async def _write_packet_to_serial(self, packet: NASAPacket):
|
||||
final_packet = packet.to_raw()
|
||||
self.writer.write(final_packet)
|
||||
await self.writer.drain()
|
||||
|
||||
@@ -2,48 +2,8 @@
|
||||
class NASAMessage:
|
||||
"""
|
||||
A class to represent a NASA message.
|
||||
Attributes
|
||||
----------
|
||||
packet_message : int
|
||||
The message packet identifier.
|
||||
packet_message_type : int
|
||||
The type of the message packet.
|
||||
packet_payload : bytes
|
||||
The payload of the message packet in bytes.
|
||||
Methods
|
||||
-------
|
||||
__str__():
|
||||
Returns a string representation of the NASAMessage instance.
|
||||
__repr__():
|
||||
Returns a string representation of the NASAMessage instance.
|
||||
"""
|
||||
def __init__(self, packet_message=0x000, packet_message_type=0, packet_payload=[0]):
|
||||
"""
|
||||
Constructs all the necessary attributes for the NASAMessage object.
|
||||
Parameters
|
||||
----------
|
||||
packet_message : int, optional
|
||||
The message packet identifier (default is 0x000).
|
||||
packet_message_type : int, optional
|
||||
The type of the message packet (default is 0).
|
||||
packet_payload : list, optional
|
||||
The payload of the message packet as a list of integers (default is [0]).
|
||||
"""
|
||||
"""
|
||||
Returns a string representation of the NASAMessage instance.
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A string representation of the NASAMessage instance.
|
||||
"""
|
||||
"""
|
||||
Returns a string representation of the NASAMessage instance.
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A string representation of the NASAMessage instance.
|
||||
"""
|
||||
|
||||
def __init__(self, packet_message=0x000, packet_message_type=0, packet_payload=[0]):
|
||||
self.packet_message: int = packet_message
|
||||
self.packet_message_type: int = packet_message_type
|
||||
self.packet_payload: bytes = bytes([int(hex(x), 16) for x in packet_payload])
|
||||
@@ -51,6 +11,7 @@ class NASAMessage:
|
||||
|
||||
def set_packet_message(self, value: int):
|
||||
self.packet_message = value
|
||||
self.packet_message_type = (value & 1536) >> 9
|
||||
|
||||
def set_packet_message_type(self, value: int):
|
||||
self.packet_message_type = value
|
||||
@@ -58,6 +19,9 @@ class NASAMessage:
|
||||
def set_packet_payload(self, value: list):
|
||||
self.packet_payload = bytes([int(hex(x), 16) for x in value])
|
||||
|
||||
def set_packet_payload_raw(self, value: bytes):
|
||||
self.packet_payload = value
|
||||
|
||||
def to_raw(self) -> bytearray:
|
||||
|
||||
message_number_reconstructed = (self.packet_message_type << 9) | (self.packet_message & 0x1FF)
|
||||
@@ -88,6 +52,12 @@ class NASAMessage:
|
||||
(msgpayload >> 8) & 0xFF,
|
||||
msgpayload & 0xFF
|
||||
]
|
||||
elif self.packet_message_type == 3:
|
||||
return [
|
||||
msg_rest_0,
|
||||
msg_rest_1,
|
||||
*[(msgpayload >> (8 * i)) & 0xFF for i in reversed(range(len(self.packet_payload)))]
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
|
||||
146
NASAPacket.py
@@ -3,38 +3,11 @@ from NASAMessage import NASAMessage
|
||||
from EHSExceptions import SkipInvalidPacketException
|
||||
import binascii
|
||||
import struct
|
||||
from CustomLogger import logger
|
||||
|
||||
class AddressClassEnum(Enum):
|
||||
"""
|
||||
Enum class representing various address classes for NASA packets.
|
||||
Attributes:
|
||||
Outdoor (int): Address class for outdoor units (0x10).
|
||||
HTU (int): Address class for HTU units (0x11).
|
||||
Indoor (int): Address class for indoor units (0x20).
|
||||
ERV (int): Address class for ERV units (0x30).
|
||||
Diffuser (int): Address class for diffuser units (0x35).
|
||||
MCU (int): Address class for MCU units (0x38).
|
||||
RMC (int): Address class for RMC units (0x40).
|
||||
WiredRemote (int): Address class for wired remote units (0x50).
|
||||
PIM (int): Address class for PIM units (0x58).
|
||||
SIM (int): Address class for SIM units (0x59).
|
||||
Peak (int): Address class for peak units (0x5A).
|
||||
PowerDivider (int): Address class for power divider units (0x5B).
|
||||
OnOffController (int): Address class for on/off controller units (0x60).
|
||||
WiFiKit (int): Address class for WiFi kit units (0x62).
|
||||
CentralController (int): Address class for central controller units (0x65).
|
||||
DMS (int): Address class for DMS units (0x6A).
|
||||
JIGTester (int): Address class for JIG tester units (0x80).
|
||||
BroadcastSelfLayer (int): Address class for broadcast self layer (0xB0).
|
||||
BroadcastControlLayer (int): Address class for broadcast control layer (0xB1).
|
||||
BroadcastSetLayer (int): Address class for broadcast set layer (0xB2).
|
||||
BroadcastCS (int): Address class for broadcast CS (0xB3).
|
||||
BroadcastControlAndSetLayer (int): Address class for broadcast control and set layer (0xB3).
|
||||
BroadcastModuleLayer (int): Address class for broadcast module layer (0xB4).
|
||||
BroadcastCSM (int): Address class for broadcast CSM (0xB7).
|
||||
BroadcastLocalLayer (int): Address class for broadcast local layer (0xB8).
|
||||
BroadcastCSML (int): Address class for broadcast CSML (0xBF).
|
||||
Undefined (int): Address class for undefined units (0xFF).
|
||||
"""
|
||||
|
||||
Outdoor = 0x10
|
||||
@@ -68,12 +41,6 @@ class AddressClassEnum(Enum):
|
||||
class PacketType(Enum):
|
||||
"""
|
||||
Enum class representing different types of packets in the EHS-Sentinel system.
|
||||
Attributes:
|
||||
StandBy (int): Represents a standby packet type with a value of 0.
|
||||
Normal (int): Represents a normal packet type with a value of 1.
|
||||
Gathering (int): Represents a gathering packet type with a value of 2.
|
||||
Install (int): Represents an install packet type with a value of 3.
|
||||
Download (int): Represents a download packet type with a value of 4.
|
||||
"""
|
||||
|
||||
StandBy = 0
|
||||
@@ -85,15 +52,6 @@ class PacketType(Enum):
|
||||
class DataType(Enum):
|
||||
"""
|
||||
Enum representing different types of data operations.
|
||||
Attributes:
|
||||
Undefined (int): Represents an undefined data type (0).
|
||||
Read (int): Represents a read operation (1).
|
||||
Write (int): Represents a write operation (2).
|
||||
Request (int): Represents a request operation (3).
|
||||
Notification (int): Represents a notification operation (4).
|
||||
Response (int): Represents a response operation (5).
|
||||
Ack (int): Represents an acknowledgment (6).
|
||||
Nack (int): Represents a negative acknowledgment (7).
|
||||
"""
|
||||
|
||||
Undefined = 0
|
||||
@@ -108,56 +66,6 @@ class DataType(Enum):
|
||||
class NASAPacket:
|
||||
"""
|
||||
A class to represent a NASA Packet.
|
||||
Attributes
|
||||
----------
|
||||
_packet_raw : bytearray
|
||||
Raw packet data.
|
||||
packet_start : int
|
||||
Start byte of the packet.
|
||||
packet_size : int
|
||||
Size of the packet.
|
||||
packet_source_address_class : AddressClassEnum
|
||||
Source address class of the packet.
|
||||
packet_source_channel : int
|
||||
Source channel of the packet.
|
||||
packet_source_address : int
|
||||
Source address of the packet.
|
||||
packet_dest_address_class : AddressClassEnum
|
||||
Destination address class of the packet.
|
||||
packet_dest_channel : int
|
||||
Destination channel of the packet.
|
||||
packet_dest_address : int
|
||||
Destination address of the packet.
|
||||
packet_information : int
|
||||
Information field of the packet.
|
||||
packet_version : int
|
||||
Version of the packet.
|
||||
packet_retry_count : int
|
||||
Retry count of the packet.
|
||||
packet_type : PacketType
|
||||
Type of the packet.
|
||||
packet_data_type : DataType
|
||||
Data type of the packet.
|
||||
packet_number : int
|
||||
Number of the packet.
|
||||
packet_capacity : int
|
||||
Capacity of the packet.
|
||||
packet_messages : list[NASAMessage]
|
||||
List of messages in the packet.
|
||||
packet_crc16 : int
|
||||
CRC16 checksum of the packet.
|
||||
packet_end : int
|
||||
End byte of the packet.
|
||||
Methods
|
||||
-------
|
||||
parse(packet: bytearray):
|
||||
Parses the given packet data.
|
||||
_extract_messages(depth: int, capacity: int, msg_rest: bytearray, return_list: list):
|
||||
Recursively extracts messages from the packet.
|
||||
__str__():
|
||||
Returns a string representation of the NASAPacket.
|
||||
__repr__():
|
||||
Returns a string representation of the NASAPacket.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -182,33 +90,6 @@ class NASAPacket:
|
||||
self.packet_end: int = None
|
||||
|
||||
def parse(self, packet: bytearray):
|
||||
"""
|
||||
Parses a given bytearray packet and extracts various fields into the object's attributes.
|
||||
Args:
|
||||
packet (bytearray): The packet to be parsed.
|
||||
Raises:
|
||||
ValueError: If the packet length is less than 14 bytes.
|
||||
Attributes:
|
||||
packet_start (int): The start byte of the packet.
|
||||
packet_size (int): The size of the packet.
|
||||
packet_source_address_class (AddressClassEnum): The source address class of the packet.
|
||||
packet_source_channel (int): The source channel of the packet.
|
||||
packet_source_address (int): The source address of the packet.
|
||||
packet_dest_address_class (AddressClassEnum): The destination address class of the packet.
|
||||
packet_dest_channel (int): The destination channel of the packet.
|
||||
packet_dest_address (int): The destination address of the packet.
|
||||
packet_information (bool): Information flag of the packet.
|
||||
packet_version (int): Version of the packet.
|
||||
packet_retry_count (int): Retry count of the packet.
|
||||
packet_type (PacketType): Type of the packet.
|
||||
packet_data_type (DataType): Data type of the packet.
|
||||
packet_number (int): Number of the packet.
|
||||
packet_capacity (int): Capacity of the packet.
|
||||
packet_crc16 (int): CRC16 checksum of the packet.
|
||||
packet_end (int): The end byte of the packet.
|
||||
packet_messages (list): Extracted messages from the packet.
|
||||
"""
|
||||
|
||||
self._packet_raw = packet
|
||||
if len(packet) < 14:
|
||||
raise ValueError("Data too short to be a valid NASAPacket")
|
||||
@@ -217,6 +98,12 @@ class NASAPacket:
|
||||
|
||||
self.packet_start = packet[0]
|
||||
self.packet_size = ((packet[1] << 8) | packet[2])
|
||||
|
||||
if self.packet_size+2 != len(packet):
|
||||
logger.info(f"length not correct {self.packet_size+2} -> {len(packet)}")
|
||||
logger.info(f"{packet.hex()}")
|
||||
logger.info(f"{hex(packet[self.packet_size+1])}")
|
||||
|
||||
try:
|
||||
self.packet_source_address_class = AddressClassEnum(packet[3])
|
||||
except ValueError as e:
|
||||
@@ -244,20 +131,6 @@ class NASAPacket:
|
||||
raise SkipInvalidPacketException(f"Checksum for package could not be validated. Calculated: {crc_checkusm} in packet: {self.packet_crc16}: packet:{self}")
|
||||
|
||||
def _extract_messages(self, depth: int, capacity: int, msg_rest: bytearray, return_list: list):
|
||||
"""
|
||||
Recursively extracts messages from a bytearray and appends them to a list.
|
||||
Args:
|
||||
depth (int): The current depth of recursion.
|
||||
capacity (int): The maximum allowed depth of recursion.
|
||||
msg_rest (bytearray): The remaining bytes to be processed.
|
||||
return_list (list): The list to which extracted messages are appended.
|
||||
Returns:
|
||||
list: The list of extracted messages.
|
||||
Raises:
|
||||
ValueError: If the message type is unknown, the capacity is invalid for a structure type message,
|
||||
or the payload size exceeds 255 bytes.
|
||||
"""
|
||||
|
||||
if depth > capacity or len(msg_rest) <= 2:
|
||||
return return_list
|
||||
|
||||
@@ -358,11 +231,6 @@ class NASAPacket:
|
||||
self.packet_messages = value
|
||||
|
||||
def to_raw(self) -> bytearray:
|
||||
"""
|
||||
Converts the NASAPacket object back to its raw byte representation.
|
||||
Returns:
|
||||
bytearray: The raw byte representation of the packet.
|
||||
"""
|
||||
self.packet_start = 50
|
||||
self.packet_end = 52
|
||||
|
||||
|
||||
77
README.md
@@ -13,6 +13,15 @@ You need an MQTT Broker.
|
||||
For Homeassistant you need the MQTT Plugin there with enabled Auto Discovery with Discovery Topic Prefix and Birth-Messages on Discovery Topic Prefix with subtopic "status" with text "online".
|
||||
EHS-Sentinel subscribes <hass_discovery_prefix>/status Topic and if it receive an "online", then it cleans his intern known-devices topic and send the Auto Discovery Config again for any Measurment for Home Assistant.
|
||||
|
||||
# Upgrade instructions
|
||||
|
||||
1. Stop EHS-Sentinel
|
||||
2. *Optional* If you are using HASS: Delete the MQTT Device
|
||||
3. git pull the new release or download and extract the release zip file
|
||||
4. Look into Release Notes if there are some new configurations and check if you have to ajust your configfile
|
||||
5. Start EHS-Sentinel (I recommend to use `--clean-known-devices` on the start so EHS-Sentinel will send Configuration messages for HASS Auto Discovery after every startup.)
|
||||
6. *Optional* If you are using HASS: and not use the `--clean-known-devices` Parm on Startup, send a birthmessage manualy or restart the MQTT Adapter in HASS.
|
||||
|
||||
# Installation
|
||||
|
||||
## Simple
|
||||
@@ -23,7 +32,9 @@ EHS-Sentinel subscribes <hass_discovery_prefix>/status Topic and if it receive a
|
||||
`pip install -r requirements.txt`
|
||||
3. Copy the `data/config.yml` and provide your Configuration
|
||||
4. Start the Application:
|
||||
`python3 startEHSSentinel.py --configfile config.yml`
|
||||
`python3 startEHSSentinel.py --configfile config.yml --clean-known-devices`
|
||||
|
||||
I recommend to use `--clean-known-devices` on the start so EHS-Sentinel will send Configuration messages for HASS Autodiscovery after every startup.
|
||||
|
||||
## Systemd Service
|
||||
|
||||
@@ -36,7 +47,9 @@ EHS-Sentinel subscribes <hass_discovery_prefix>/status Topic and if it receive a
|
||||
|
||||
`ExecStart = python3 <Path of the script you want to run>` <- provide here to path to your folder where startEHSSentinel.py is
|
||||
|
||||
sample: `ExecStart = python3 /root/EHS-Sentinel/startEHSSentinel.py --configfile /root/EHS-Sentinel/config.yml`
|
||||
sample: `ExecStart = python3 /root/EHS-Sentinel/startEHSSentinel.py --configfile /root/EHS-Sentinel/config.yml --clean-known-devices`
|
||||
|
||||
I recommend to use `--clean-known-devices` on the start so EHS-Sentinel will send Configuration messages for HASS Autodiscovery after every startup.`
|
||||
|
||||
5. Change your `config.yml` to absolut paths:
|
||||
`nasaRepositoryFile: /root/EHS-Sentinel/data/NasaRepository.yml`
|
||||
@@ -92,7 +105,9 @@ Some Distributions like debian 12 dont allow to use system wide pip package inst
|
||||
|
||||
`ExecStart = <path to python3> <Path of the script you want to run>` <- provide here to path to your folder where startEHSSentinel.py is
|
||||
|
||||
sample: `ExecStart = /root/EHS-Sentinel/bin/python3 /root/EHS-Sentinel/startEHSSentinel.py --configfile /root/EHS-Sentinel/config.yml`
|
||||
sample: `ExecStart = /root/EHS-Sentinel/bin/python3 /root/EHS-Sentinel/startEHSSentinel.py --configfile /root/EHS-Sentinel/config.yml --clean-known-devices`
|
||||
|
||||
I recommend to use `--clean-known-devices` on the start so EHS-Sentinel will send Configuration messages for HASS Autodiscovery after every startup.
|
||||
|
||||
10. Change your `config.yml` to absolut paths:
|
||||
`nasaRepositoryFile: /root/EHS-Sentinel/data/NasaRepository.yml`
|
||||
@@ -121,16 +136,31 @@ Some Distributions like debian 12 dont allow to use system wide pip package inst
|
||||
|
||||
# Home Assistant Dashboard
|
||||
|
||||
There is a rudimentary dasdboard for Homeassistant, this can be found at: [ressources/dashboard.yaml](ressources/dashboard.yaml)
|
||||
There are two rudimentary dashboard templates for Homeassistant,
|
||||
Read Only [ressources/dashboard_readonly_template.yaml](ressources/dashboard_readonly_template.yaml)
|
||||
|
||||
Control mode [ressources/dashboard_controlmode_template.yaml](ressources/dashboard_controlmode_template.yaml)
|
||||
|
||||
If you have good ideas and want to extend this feel free to create an issue or pull request, thanks!
|
||||
|
||||
## Read Only Mode
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
## Control Mode
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
# Configuration
|
||||
|
||||
## Command-Line Arguments
|
||||
@@ -182,6 +212,16 @@ The `config.yml` file contains configuration settings for the EHS-Sentinel proje
|
||||
- Default: `data/NasaRepository.yml`
|
||||
- **protocolFile**: Path to the protocol file. (not set in Sample config.yml)
|
||||
- Example: `prot.csv`
|
||||
- **allowControl**: Allows EHS-Sentinel to Control the Heatpump. HASS Entities are writable, EHS-Sentinel listents to set Topic and write published values to th Modbus Interface.
|
||||
The Set Topic have following pattern: <topicPrefix>/entity/<NASA_NAME>/set sample: ehsSentinel/ENUM_IN_SG_READY_MODE_STATE/set
|
||||
- Default: `False`
|
||||
|
||||
> [!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.
|
||||
|
||||
### Logging Settings
|
||||
|
||||
@@ -195,6 +235,10 @@ The `config.yml` file contains configuration settings for the EHS-Sentinel proje
|
||||
- Default: `False`
|
||||
- **pollerMessage**: set to true, prints out detailed poller NASAPackets
|
||||
- Default: `False`
|
||||
- **controlMessage**: set to true, prints out detailed control Message NASAPackets
|
||||
- Default: `False`
|
||||
- **invalidPacket**: set to true, prints out invalid packets like length not ok or end byte not 0x34...
|
||||
- Default: `False`
|
||||
|
||||
### Serial Connection Settings
|
||||
cannot be defined with TCP parm...
|
||||
@@ -267,6 +311,7 @@ The data points are defined in the groups section, the group is then enabled in
|
||||
```yaml
|
||||
general:
|
||||
nasaRepositoryFile: data/NasaRepository.yml
|
||||
allowControl: False
|
||||
# protocolFile: prot.csv
|
||||
logging:
|
||||
deviceAdded: True
|
||||
@@ -333,6 +378,30 @@ if you want to see how many uniquie Messages have been collected in the Dumpfile
|
||||
|
||||
# Changelog
|
||||
|
||||
### v1.0.0 - 2025-03-13
|
||||
- EHS-Sentinel has been heavily modified to incorporate the control mechanism
|
||||
- The read-in behavior of the modbus registers has been revised from chunks to single byte
|
||||
- MessageProcessor now runs asynchronously
|
||||
- MessageProducer added which takes over the writing communication with the WP
|
||||
- Configuration of HASS entities has moved from hardcoded to NASA Repository
|
||||
- NASA Repository has been fundamentally changed
|
||||
- All FSV Values, NASA_POWER, VAR_IN_TEMP_WATER_LAW_TARGET_F, NASA_INDOOR_OPMODE are allowed for writing mode
|
||||
- NASA_OUTDOOR_DEFROST_STEP DEFROST STEP 10 (b'0xa') added
|
||||
- ENUM_IN_SG_READY_MODE_STATE ACTIVE (b'0x2') added
|
||||
- New configuration point allowControl to allow control of the Samsung EHS heat pump (deactivated by default).
|
||||
|
||||
> [!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.
|
||||
|
||||
- new configuration points in logging
|
||||
- controlMessage (default False) to print out the controlled mesagges
|
||||
- invalidPacket (default False) prints out invalid messages (length not ok, x34 not at end...)
|
||||
- Dashboard template has been split, [ressources/dashboard_readonly_template.yaml](ressources/dashboard_readonly_template.yaml) is for readonly mode and the [ressources/dashboard_controlmode_template.yaml](ressources/dashboard_controlmode_template.yaml) for control mode
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
general:
|
||||
nasaRepositoryFile: data/NasaRepository.yml
|
||||
allowControl: False
|
||||
logging:
|
||||
deviceAdded: True
|
||||
messageNotFound: False
|
||||
packetNotFromIndoorOutdoor: False
|
||||
proccessedMessage: False
|
||||
pollerMessage: False
|
||||
controlMessage: False
|
||||
invalidPacket: False
|
||||
#serial:
|
||||
# device: /dev/ttyUSB0
|
||||
# baudrate: 9600
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import json
|
||||
|
||||
import os
|
||||
import sys
|
||||
import inspect
|
||||
|
||||
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
parentdir = os.path.dirname(currentdir)
|
||||
sys.path.insert(0, parentdir)
|
||||
|
||||
import NASAPacket
|
||||
import NASAMessage
|
||||
|
||||
@@ -8,6 +17,7 @@ encode_raw = "[50, 0, 60, 16, 0, 0, 176, 0, 255, 192, 20, 196, 13, 2, 2, 255, 25
|
||||
encode_raw = "[50, 0, 56, 98, 0, 144, 178, 0, 32, 192, 17, 3, 11, 64, 147, 0, 64, 148, 0, 66, 115, 0, 0, 66, 116, 0, 0, 66, 117, 0, 0, 66, 118, 0, 0, 66, 119, 0, 0, 66, 120, 0, 0, 66, 121, 0, 0, 66, 122, 0, 0, 66, 123, 0, 0, 221, 200, 52]"
|
||||
encode_raw = "[50, 0, 56, 98, 0, 144, 178, 0, 32, 192, 17, 240, 11, 64, 147, 0, 64, 148, 0, 66, 115, 0, 0, 66, 116, 0, 0, 66, 117, 0, 0, 66, 118, 0, 0, 66, 119, 0, 0, 66, 120, 0, 0, 66, 121, 0, 0, 66, 122, 0, 0, 66, 123, 0, 0, 76, 33, 52]"
|
||||
#encode_raw ="[50, 0, 48, 98, 0, 144, 178, 0, 32, 192, 17, 240, 11, 64, 147, 0, 64, 148, 0, 66, 115, 0, 0, 66, 116, 0, 66, 117, 0, 66, 118, 0, 66, 119, 0, 66, 120, 0, 66, 121, 0, 66, 122, 0, 66, 123, 0, 7, 180, 52]"
|
||||
encode_raw = "['0x32', '0x00', '0x1A', '0x80', '0xFF', '0x00', '0x20', '0x00', '0x00', '0xC0', '0x11', '0xB0', '0x01', '0x06', '0x07', '0x00', '0x00', '0x00', '0x00', '0x00', '0x00', '0x00', '0x00', '0x00', '0x00', '0xBD', '0xBC', '0x34']"
|
||||
try:
|
||||
encode_bytearray = json.loads(encode_raw.strip()) # for [12, 234, 456 ,67]
|
||||
except:
|
||||
@@ -23,10 +33,10 @@ encoded_nasa = NASAPacket.NASAPacket()
|
||||
encoded_nasa.parse(encode_bytearray)
|
||||
|
||||
print(f"encode NASA Object: {encoded_nasa}")
|
||||
|
||||
exit()
|
||||
# time to reverse that thing!
|
||||
decoded_nasa = NASAPacket.NASAPacket()
|
||||
decoded_nasa.set_packet_source_address_class(NASAPacket.AddressClassEnum.Outdoor)
|
||||
decoded_nasa.set_packet_source_address_class(NASAPacket.AddressClassEnum.JIGTester)
|
||||
decoded_nasa.set_packet_source_channel(0)
|
||||
decoded_nasa.set_packet_source_address(0)
|
||||
decoded_nasa.set_packet_dest_address_class(NASAPacket.AddressClassEnum.BroadcastSelfLayer)
|
||||
|
||||
159
helpertils/messageFinder.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
import sys
|
||||
import inspect
|
||||
import asyncio
|
||||
import yaml
|
||||
import traceback
|
||||
|
||||
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
parentdir = os.path.dirname(currentdir)
|
||||
sys.path.insert(0, parentdir)
|
||||
|
||||
from NASAPacket import NASAPacket, AddressClassEnum, DataType, PacketType
|
||||
from NASAMessage import NASAMessage
|
||||
|
||||
# Generate a list of all possible 2-byte hex values, always padded to 4 characters
|
||||
two_byte_hex_values = [f"0x{i:04X}" for i in range(0x0000, 0xFFFF)]
|
||||
send_message_list = []
|
||||
seen_message_list = []
|
||||
|
||||
with open('data/NasaRepository.yml', mode='r') as file:
|
||||
NASA_REPO = yaml.safe_load(file)
|
||||
|
||||
async def main():
|
||||
|
||||
# load config
|
||||
with open('config.yml', mode='r') as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
# Print the total count to confirm all values are included
|
||||
print(f"Total values: {len(two_byte_hex_values)}")
|
||||
|
||||
reader, writer = await asyncio.open_connection('172.19.2.240', 4196)
|
||||
print(" serial_connection fertig")
|
||||
await asyncio.gather(
|
||||
serial_read(reader, config),
|
||||
serial_write(writer, config),
|
||||
)
|
||||
|
||||
async def serial_write(writer: asyncio.StreamWriter, config):
|
||||
_CHUNKSIZE=10
|
||||
chunks = [two_byte_hex_values[i:i + _CHUNKSIZE] for i in range(0, len(two_byte_hex_values), _CHUNKSIZE)]
|
||||
for chunk in chunks:
|
||||
nasa_msg = NASAPacket()
|
||||
nasa_msg.set_packet_source_address_class(AddressClassEnum.JIGTester)
|
||||
nasa_msg.set_packet_source_channel(240)
|
||||
nasa_msg.set_packet_source_address(0)
|
||||
nasa_msg.set_packet_dest_address_class(AddressClassEnum.BroadcastSetLayer)
|
||||
nasa_msg.set_packet_dest_channel(0)
|
||||
nasa_msg.set_packet_dest_address(32)
|
||||
nasa_msg.set_packet_information(True)
|
||||
nasa_msg.set_packet_version(2)
|
||||
nasa_msg.set_packet_retry_count(0)
|
||||
nasa_msg.set_packet_type(PacketType.Normal)
|
||||
nasa_msg.set_packet_data_type(DataType.Read)
|
||||
nasa_msg.set_packet_number(166)
|
||||
msglist=[]
|
||||
for msg in chunk:
|
||||
if msg not in send_message_list and msg not in seen_message_list:
|
||||
tmpmsg = NASAMessage()
|
||||
tmpmsg.set_packet_message(int(msg, 16))
|
||||
value = 0
|
||||
if tmpmsg.packet_message_type == 0:
|
||||
value_raw = value.to_bytes(1, byteorder='big')
|
||||
elif tmpmsg.packet_message_type == 1:
|
||||
value_raw = value.to_bytes(2, byteorder='big')
|
||||
elif tmpmsg.packet_message_type == 2:
|
||||
value_raw = value.to_bytes(4, byteorder='big')
|
||||
else:
|
||||
value_raw = value.to_bytes(1, byteorder='big')
|
||||
|
||||
tmpmsg.set_packet_payload_raw(value_raw)
|
||||
msglist.append(tmpmsg)
|
||||
nasa_msg.set_packet_messages(msglist)
|
||||
raw = nasa_msg.to_raw()
|
||||
writer.write(raw)
|
||||
await writer.drain()
|
||||
send_message_list.extend(chunk)
|
||||
if len(send_message_list) % 100 == 0:
|
||||
print(f"Sended count: {len(send_message_list)}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def serial_read(reader: asyncio.StreamReader, config):
|
||||
prev_byte = 0x00
|
||||
packet_started = False
|
||||
data = bytearray()
|
||||
packet_size = 0
|
||||
|
||||
while True:
|
||||
current_byte = await reader.read(1) # read bitewise
|
||||
|
||||
#data = await reader.read(1024)
|
||||
#data = await reader.readuntil(b'\x34fd')
|
||||
if current_byte:
|
||||
if packet_started:
|
||||
data.extend(current_byte)
|
||||
if len(data) == 3:
|
||||
packet_size = ((data[1] << 8) | data[2]) + 2
|
||||
|
||||
if packet_size <= len(data):
|
||||
asyncio.create_task(process_packet(data, config))
|
||||
data = bytearray()
|
||||
packet_started = False
|
||||
|
||||
# identify packet start
|
||||
if current_byte == b'\x00' and prev_byte == b'\x32':
|
||||
packet_started = True
|
||||
data.extend(prev_byte)
|
||||
data.extend(current_byte)
|
||||
|
||||
prev_byte = current_byte
|
||||
|
||||
def search_nasa_table(address):
|
||||
for key in NASA_REPO:
|
||||
if NASA_REPO[key]['address'].lower() == address.lower():
|
||||
return key
|
||||
|
||||
def is_valid_rawvalue(rawvalue: bytes) -> bool:
|
||||
return all(0x20 <= b <= 0x7E or b in (0x00, 0xFF) for b in rawvalue)
|
||||
|
||||
async def process_packet(buffer, config):
|
||||
try:
|
||||
nasa_packet = NASAPacket()
|
||||
nasa_packet.parse(buffer)
|
||||
for msg in nasa_packet.packet_messages:
|
||||
if msg.packet_message not in seen_message_list:
|
||||
seen_message_list.append(msg.packet_message)
|
||||
msgkey = search_nasa_table(f"0x{msg.packet_message:04X}")
|
||||
if msgkey is None:
|
||||
msgkey = ""
|
||||
msgvalue = None
|
||||
if msg.packet_message_type == 3:
|
||||
msgvalue = ""
|
||||
|
||||
if is_valid_rawvalue(msg.packet_payload[1:-1]):
|
||||
for byte in msg.packet_payload[1:-1]:
|
||||
if byte != 0x00 and byte != 0xFF:
|
||||
char = chr(byte) if 32 <= byte <= 126 else f"{byte}"
|
||||
msgvalue += char
|
||||
else:
|
||||
msgvalue += " "
|
||||
msgvalue = msgvalue.strip()
|
||||
else:
|
||||
msgvalue = "".join([f"{int(x)}" for x in msg.packet_payload])
|
||||
else:
|
||||
msgvalue = int.from_bytes(msg.packet_payload, byteorder='big', signed=True)
|
||||
|
||||
line = f"| {len(seen_message_list):<6} | {hex(msg.packet_message):<6} | {msgkey:<50} | {msg.packet_message_type} | {msgvalue:<20} | {msg.packet_payload} |"
|
||||
with open('helpertils/messagesFound.txt', "a") as dumpWriter:
|
||||
dumpWriter.write(f"{line}\n")
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
except RuntimeError as e:
|
||||
print(f"Runtime error: {e}")
|
||||
169
helpertils/messageFinderAnalyser.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
import sys
|
||||
import inspect
|
||||
import asyncio
|
||||
import yaml
|
||||
import traceback
|
||||
import pprint
|
||||
|
||||
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
parentdir = os.path.dirname(currentdir)
|
||||
sys.path.insert(0, parentdir)
|
||||
|
||||
from NASAPacket import NASAPacket, AddressClassEnum, DataType, PacketType
|
||||
from NASAMessage import NASAMessage
|
||||
|
||||
# Generate a list of all possible 2-byte hex values, always padded to 4 characters
|
||||
found_repo = {}
|
||||
|
||||
with open('data/NasaRepository.yml', mode='r') as file:
|
||||
NASA_REPO = yaml.safe_load(file)
|
||||
|
||||
async def main():
|
||||
|
||||
# load finder file and anylse it
|
||||
with open('helpertils/messagesFound.txt', mode='r') as file:
|
||||
lines = file.read()
|
||||
for line in lines.splitlines():
|
||||
nix, nr, msgnr, msgname, type, packedval, rawval, nix2 = line.split("|")
|
||||
if type.strip() != '3':
|
||||
packedval = int(packedval.strip())
|
||||
if len(msgname.strip()) == 0 and packedval != -1:
|
||||
found_repo[msgnr.strip()] = {
|
||||
"type": type.strip(),
|
||||
"raw_value": rawval.strip(),
|
||||
"packed_value": packedval
|
||||
}
|
||||
|
||||
pprint.pprint(found_repo)
|
||||
|
||||
# load config
|
||||
with open('config.yml', mode='r') as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
# Print the total count to confirm all values are included
|
||||
print(f"Total values: {len(found_repo)}")
|
||||
|
||||
reader, writer = await asyncio.open_connection('172.19.2.240', 4196)
|
||||
print(" serial_connection fertig")
|
||||
await asyncio.gather(
|
||||
serial_read(reader, config),
|
||||
serial_write(writer, config),
|
||||
)
|
||||
|
||||
async def serial_write(writer: asyncio.StreamWriter, config):
|
||||
_CHUNKSIZE=10
|
||||
keys = list(found_repo.keys())
|
||||
chunks = [keys[i:i + _CHUNKSIZE] for i in range(0, len(keys), _CHUNKSIZE)]
|
||||
while True:
|
||||
print("Start Writing")
|
||||
for chunk in chunks:
|
||||
nasa_msg = NASAPacket()
|
||||
nasa_msg.set_packet_source_address_class(AddressClassEnum.JIGTester)
|
||||
nasa_msg.set_packet_source_channel(240)
|
||||
nasa_msg.set_packet_source_address(0)
|
||||
nasa_msg.set_packet_dest_address_class(AddressClassEnum.BroadcastSetLayer)
|
||||
nasa_msg.set_packet_dest_channel(0)
|
||||
nasa_msg.set_packet_dest_address(32)
|
||||
nasa_msg.set_packet_information(True)
|
||||
nasa_msg.set_packet_version(2)
|
||||
nasa_msg.set_packet_retry_count(0)
|
||||
nasa_msg.set_packet_type(PacketType.Normal)
|
||||
nasa_msg.set_packet_data_type(DataType.Read)
|
||||
nasa_msg.set_packet_number(166)
|
||||
msglist=[]
|
||||
for msg in chunk:
|
||||
tmpmsg = NASAMessage()
|
||||
tmpmsg.set_packet_message(int(msg, 16))
|
||||
value = 0
|
||||
if tmpmsg.packet_message_type == 0:
|
||||
value_raw = value.to_bytes(1, byteorder='big')
|
||||
elif tmpmsg.packet_message_type == 1:
|
||||
value_raw = value.to_bytes(2, byteorder='big')
|
||||
elif tmpmsg.packet_message_type == 2:
|
||||
value_raw = value.to_bytes(4, byteorder='big')
|
||||
else:
|
||||
value_raw = value.to_bytes(1, byteorder='big')
|
||||
|
||||
tmpmsg.set_packet_payload_raw(value_raw)
|
||||
msglist.append(tmpmsg)
|
||||
nasa_msg.set_packet_messages(msglist)
|
||||
raw = nasa_msg.to_raw()
|
||||
writer.write(raw)
|
||||
await writer.drain()
|
||||
await asyncio.sleep(1)
|
||||
print("End Writing")
|
||||
await asyncio.sleep(120)
|
||||
|
||||
async def serial_read(reader: asyncio.StreamReader, config):
|
||||
prev_byte = 0x00
|
||||
packet_started = False
|
||||
data = bytearray()
|
||||
packet_size = 0
|
||||
|
||||
while True:
|
||||
current_byte = await reader.read(1) # read bitewise
|
||||
|
||||
#data = await reader.read(1024)
|
||||
#data = await reader.readuntil(b'\x34fd')
|
||||
if current_byte:
|
||||
if packet_started:
|
||||
data.extend(current_byte)
|
||||
if len(data) == 3:
|
||||
packet_size = ((data[1] << 8) | data[2]) + 2
|
||||
|
||||
if packet_size <= len(data):
|
||||
asyncio.create_task(process_packet(data, config))
|
||||
data = bytearray()
|
||||
packet_started = False
|
||||
|
||||
# identify packet start
|
||||
if current_byte == b'\x00' and prev_byte == b'\x32':
|
||||
packet_started = True
|
||||
data.extend(prev_byte)
|
||||
data.extend(current_byte)
|
||||
|
||||
prev_byte = current_byte
|
||||
|
||||
def search_nasa_table(address):
|
||||
for key in NASA_REPO:
|
||||
if NASA_REPO[key]['address'].lower() == address.lower():
|
||||
return key
|
||||
|
||||
def is_valid_rawvalue(rawvalue: bytes) -> bool:
|
||||
return all(0x20 <= b <= 0x7E or b in (0x00, 0xFF) for b in rawvalue)
|
||||
|
||||
async def process_packet(buffer, config):
|
||||
try:
|
||||
nasa_packet = NASAPacket()
|
||||
nasa_packet.parse(buffer)
|
||||
for msg in nasa_packet.packet_messages:
|
||||
if f"0x{msg.packet_message:04X}" in found_repo:
|
||||
msgvalue = None
|
||||
if msg.packet_message_type == 3:
|
||||
msgvalue = ""
|
||||
|
||||
if is_valid_rawvalue(msg.packet_payload[1:-1]):
|
||||
for byte in msg.packet_payload[1:-1]:
|
||||
if byte != 0x00 and byte != 0xFF:
|
||||
char = chr(byte) if 32 <= byte <= 126 else f"{byte}"
|
||||
msgvalue += char
|
||||
else:
|
||||
msgvalue += " "
|
||||
msgvalue = msgvalue.strip()
|
||||
else:
|
||||
msgvalue = "".join([f"{int(x)}" for x in msg.packet_payload])
|
||||
else:
|
||||
msgvalue = int.from_bytes(msg.packet_payload, byteorder='big', signed=True)
|
||||
if msgvalue != found_repo[f"0x{msg.packet_message:04X}"]['packed_value']:
|
||||
line = f" {hex(msg.packet_message):<6} | {msg.packet_message_type} | {found_repo[f"0x{msg.packet_message:04X}"]['packed_value']} -> {msgvalue} |"
|
||||
print(line)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
except RuntimeError as e:
|
||||
print(f"Runtime error: {e}")
|
||||
97
helpertils/refreshNasaRepository.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import argparse
|
||||
import yaml
|
||||
|
||||
def replace_empty_with_null(d):
|
||||
|
||||
if isinstance(d, dict):
|
||||
return {k: replace_empty_with_null(v) for k, v in d.items()}
|
||||
elif isinstance(d, list):
|
||||
return [replace_empty_with_null(v) for v in d]
|
||||
elif d is None:
|
||||
return None # Ensure `None` stays as YAML `null`
|
||||
elif len(d.strip()) == 0:
|
||||
return None
|
||||
return d
|
||||
|
||||
def main():
|
||||
with open('data/NasaRepository.yml', 'r') as nasarepo:
|
||||
old_yaml = yaml.safe_load(nasarepo)
|
||||
|
||||
ele = {}
|
||||
|
||||
for key, value in old_yaml.items():
|
||||
print(key)
|
||||
ele[key] = {}
|
||||
ele[key]['hass_opts'] = {}
|
||||
ele[key]['hass_opts']['platform'] = {}
|
||||
ele[key]['address'] = replace_empty_with_null(old_yaml[key]['address'])
|
||||
if replace_empty_with_null(old_yaml[key]['arithmetic']) is not None:
|
||||
ele[key]['arithmetic'] = old_yaml[key]['arithmetic']
|
||||
if 'description' in old_yaml[key] and replace_empty_with_null(old_yaml[key]['description']) is not None:
|
||||
ele[key]['description'] = old_yaml[key]['description']
|
||||
ele[key]['hass_opts']['default_platform'] = "sensor"
|
||||
if 'writable' in old_yaml[key]:
|
||||
ele[key]['hass_opts']['writable'] = old_yaml[key]['writable']
|
||||
else:
|
||||
ele[key]['hass_opts']['writable'] = False
|
||||
if 'enum' in old_yaml[key]:
|
||||
new_values = [x.replace("'", "") for x in old_yaml[key]['enum'].values()]
|
||||
if all([en.lower() in ['on', 'off'] for en in new_values]):
|
||||
ele[key]['enum'] = old_yaml[key]['enum']
|
||||
ele[key]['hass_opts']['default_platform'] = "binary_sensor"
|
||||
ele[key]['hass_opts']['platform']['payload_off'] = 'OFF'
|
||||
ele[key]['hass_opts']['platform']['payload_on'] = 'ON'
|
||||
ele[key]['hass_opts']['platform']['type'] = 'switch'
|
||||
else:
|
||||
ele[key]['enum'] = old_yaml[key]['enum']
|
||||
ele[key]['hass_opts']['platform']['options'] = new_values
|
||||
ele[key]['hass_opts']['platform']['type'] = 'select'
|
||||
else:
|
||||
if 'min' in old_yaml[key]:
|
||||
ele[key]['hass_opts']['platform']['min'] = old_yaml[key]['min']
|
||||
if 'max' in old_yaml[key]:
|
||||
ele[key]['hass_opts']['platform']['max'] = old_yaml[key]['max']
|
||||
if 'step' in old_yaml[key]:
|
||||
ele[key]['hass_opts']['platform']['step'] = old_yaml[key]['step']
|
||||
ele[key]['hass_opts']['platform']['type'] = 'number'
|
||||
if replace_empty_with_null(old_yaml[key]['remarks']) is not None:
|
||||
ele[key]['remarks'] = old_yaml[key]['remarks']
|
||||
if replace_empty_with_null(old_yaml[key]['signed']) is not None:
|
||||
ele[key]['signed'] = old_yaml[key]['signed']
|
||||
if replace_empty_with_null(old_yaml[key]['type']) is not None:
|
||||
ele[key]['type'] = old_yaml[key]['type']
|
||||
|
||||
if 'state_class' in old_yaml[key]:
|
||||
ele[key]['hass_opts']['state_class'] = old_yaml[key]['state_class']
|
||||
if 'device_class' in old_yaml[key]:
|
||||
ele[key]['hass_opts']['device_class'] = old_yaml[key]['device_class']
|
||||
|
||||
if 'unit' in old_yaml[key]:
|
||||
if replace_empty_with_null(old_yaml[key]['unit']) is not None:
|
||||
ele[key]['hass_opts']['unit'] = old_yaml[key]['unit']
|
||||
if ele[key]['hass_opts']['unit'] == "\u00b0C":
|
||||
ele[key]['hass_opts']['device_class'] = "temperature"
|
||||
elif ele[key]['hass_opts']['unit'] == '%':
|
||||
ele[key]['hass_opts']['state_class'] = "measurement"
|
||||
elif ele[key]['hass_opts']['unit'] == 'kW':
|
||||
ele[key]['hass_opts']['device_class'] = "power"
|
||||
elif ele[key]['hass_opts']['unit'] == 'rpm':
|
||||
ele[key]['hass_opts']['state_class'] = "measurement"
|
||||
elif ele[key]['hass_opts']['unit'] == 'bar':
|
||||
ele[key]['hass_opts']['device_class'] = "pressure"
|
||||
elif ele[key]['hass_opts']['unit'] == 'HP':
|
||||
ele[key]['hass_opts']['device_class'] = "power"
|
||||
elif ele[key]['hass_opts']['unit'] == 'hz':
|
||||
ele[key]['hass_opts']['device_class'] = "frequency"
|
||||
elif ele[key]['hass_opts']['unit'] == 'min':
|
||||
ele[key]['hass_opts']['device_class'] = "duration"
|
||||
elif ele[key]['hass_opts']['unit'] == 'h':
|
||||
ele[key]['hass_opts']['device_class'] = "duration"
|
||||
elif ele[key]['hass_opts']['unit'] == 's':
|
||||
ele[key]['hass_opts']['device_class'] = "duration"
|
||||
|
||||
with open('data/NasaRepository.yml', 'w') as newyaml:
|
||||
yaml.dump(ele, newyaml, default_flow_style=False)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
helpertils/smartthings.http
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
### list devices
|
||||
GET https://api.smartthings.com/v1/devices
|
||||
Authorization: Bearer xx
|
||||
|
||||
### "deviceId": "xx" "id": "samsungce.ehsFsvSettings", "version": 1
|
||||
GET https://api.smartthings.com/v1/capabilities/samsungce.ehsFsvSettings/1
|
||||
Authorization: Bearer xx
|
||||
|
||||
### "locationId": "xx" "name": "Mein Zuhause"
|
||||
GET https://api.smartthings.com/v1/locations
|
||||
Authorization: Bearer xx
|
||||
|
||||
###
|
||||
GET https://api.smartthings.com/v1/capabilities/samsungce.ehsFsvSettings/1/i18n/en
|
||||
Authorization: Bearer xx
|
||||
462
ressources/dashboard_controlmode_template.yaml
Normal file
@@ -0,0 +1,462 @@
|
||||
views:
|
||||
- title: Overview
|
||||
type: sections
|
||||
max_columns: 4
|
||||
subview: false
|
||||
sections:
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: select.samsung_ehssentinel_power
|
||||
name: Heatpump Power
|
||||
secondary_info: last-updated
|
||||
icon: mdi:power
|
||||
- entity: number.samsung_ehssentinel_intempwaterlawtargetf
|
||||
name: Adjust heating curve
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_indooropmode
|
||||
name: Operation Mode
|
||||
secondary_info: last-updated
|
||||
title: Control Heatpump
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: sensor.samsung_ehssentinel_outdooroperationstatus
|
||||
name: Operation Status
|
||||
secondary_info: last-updated
|
||||
- entity: binary_sensor.samsung_ehssentinel_dhwpower
|
||||
secondary_info: last-updated
|
||||
name: DHW Power
|
||||
- entity: sensor.samsung_ehssentinel_outdoordefroststep
|
||||
name: Defrost Step
|
||||
secondary_info: last-updated
|
||||
- entity: binary_sensor.samsung_ehssentinel_controlsilence
|
||||
name: Silent Mode
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_ehssentinelheatoutput
|
||||
name: Heat Output
|
||||
secondary_info: last-updated
|
||||
icon: mdi:heat-wave
|
||||
- entity: sensor.samsung_ehssentinel_ingeneratedpowerlastminute
|
||||
name: Generated Power Last Minute
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_inflowsensorcalc
|
||||
secondary_info: last-changed
|
||||
name: Water Flow Speed
|
||||
- entity: sensor.samsung_ehssentinel_ehssentinelcop
|
||||
name: COP
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_outdoortw1temp
|
||||
name: Return Temperature
|
||||
secondary_info: last-updated
|
||||
icon: mdi:waves-arrow-left
|
||||
- entity: sensor.samsung_ehssentinel_outdoortw2temp
|
||||
name: Flow Temperature
|
||||
secondary_info: last-updated
|
||||
icon: mdi:waves-arrow-right
|
||||
- entity: sensor.samsung_ehssentinel_indoordhwcurrenttemp
|
||||
name: DHW Tank Temperature
|
||||
secondary_info: last-updated
|
||||
icon: mdi:water-boiler
|
||||
- entity: sensor.samsung_ehssentinel_outdoorouttemp
|
||||
secondary_info: last-updated
|
||||
name: Outdoor Temperatur
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcomp1targethz
|
||||
name: Compressor Target Frequence
|
||||
secondary_info: last-updated
|
||||
icon: mdi:sine-wave
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcomp1runhz
|
||||
name: Compressor Run Frequence
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcomp1orderhz
|
||||
name: Compressor Order Frequence
|
||||
secondary_info: last-updated
|
||||
title: Current Data
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: sensor.samsung_ehssentinel_intotalgeneratedpower
|
||||
name: Total Generated Heat Output
|
||||
secondary_info: last-updated
|
||||
icon: mdi:heat-wave
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcontrolwattmeterallunitaccum
|
||||
name: Total Consumed Power
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_ehssentineltotalcop
|
||||
name: Total COP
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_inminutessinceinstallation
|
||||
name: Total Minutes Since Installation
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_inminutesactive
|
||||
name: Total Minutes Active
|
||||
secondary_info: last-updated
|
||||
title: Life Cycle Data
|
||||
- type: grid
|
||||
cards:
|
||||
- type: history-graph
|
||||
entities:
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcomp1orderhz
|
||||
name: Compressor freq.
|
||||
- entity: sensor.samsung_ehssentinel_outdoorfanrpm1
|
||||
name: Outdoor FAN Speed
|
||||
logarithmic_scale: false
|
||||
title: Outdoor Unit
|
||||
hours_to_show: 6
|
||||
grid_options:
|
||||
columns: full
|
||||
rows: 10
|
||||
- type: history-graph
|
||||
entities:
|
||||
- entity: sensor.samsung_ehssentinel_outdoortw1temp
|
||||
name: Return Temperature
|
||||
- entity: sensor.samsung_ehssentinel_outdoortw2temp
|
||||
name: Flow Temperature
|
||||
logarithmic_scale: false
|
||||
hours_to_show: 6
|
||||
grid_options:
|
||||
columns: full
|
||||
rows: 10
|
||||
title: Water Law
|
||||
- type: history-graph
|
||||
entities:
|
||||
- entity: sensor.samsung_ehssentinel_ehssentinelheatoutput
|
||||
name: Heat Output
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcontrolwattmeterallunit
|
||||
name: Power Input
|
||||
- entity: sensor.samsung_ehssentinel_ehssentinelcop
|
||||
name: COP
|
||||
logarithmic_scale: false
|
||||
hours_to_show: 6
|
||||
grid_options:
|
||||
columns: full
|
||||
rows: 16
|
||||
title: Efficiency
|
||||
column_span: 3
|
||||
- type: sections
|
||||
max_columns: 6
|
||||
title: Field Setting Value
|
||||
path: field-setting-value
|
||||
sections:
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: number.samsung_ehssentinel_infsv1011
|
||||
name: Water Out Temp. for Cooling Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1012
|
||||
name: Water Out Temp. for Cooling Min.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1021
|
||||
name: Room Temp. for Cooling Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1022
|
||||
name: Room Temp. for Cooling Min.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1031
|
||||
name: Water Out Temp. for Heating Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1032
|
||||
name: Water Out Temp. for Heating Min.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1041
|
||||
name: Room Temp. for Heating Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1042
|
||||
name: Room Temp. for Heating Min.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1051
|
||||
name: DHW tank Temp. Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv1052
|
||||
name: DHW tank Temp. Min.
|
||||
secondary_info: last-updated
|
||||
title: FSV 10** - Remote Controller
|
||||
show_header_toggle: false
|
||||
state_color: false
|
||||
grid_options:
|
||||
columns: full
|
||||
column_span: 2
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: number.samsung_ehssentinel_infsv2011
|
||||
name: Heating Outdoor Temp. for WL Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2012
|
||||
name: Heating Outdoor Temp. for WL Min.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2021
|
||||
name: Heating Water out Temp. UFH/WL1 Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2022
|
||||
name: Heating Water out Temp. UFH/WL1 Min.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2031
|
||||
name: Heating Water out Temp. FCU/WL2 Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2032
|
||||
name: Heating Water out Temp. FCU/WL2 Min.
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv2041
|
||||
name: Heating WL Selection
|
||||
icon: mdi:heating-coil
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2051
|
||||
name: Cooling Outdoor Temp. for WL Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2052
|
||||
name: Cooling Outdoor Temp. for WL Min.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2061
|
||||
name: Cooling Water out Temp UFH/WL1 Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2062
|
||||
name: Cooling Water out Temp. UFH/WL1 Min.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2071
|
||||
name: Cooling Water out Temp. FCU/WL2 Max.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv2072
|
||||
name: Cooling Water out Temp. FCU/WL2 Min.
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv2081
|
||||
name: Cooling WL Selection
|
||||
secondary_info: last-updated
|
||||
icon: mdi:snowflake
|
||||
- entity: select.samsung_ehssentinel_infsv2091
|
||||
name: External Room Thermostat UFH
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv2092
|
||||
name: External Room Thermostat FCU
|
||||
secondary_info: last-updated
|
||||
title: FSV 20** - Water Law
|
||||
grid_options:
|
||||
columns: full
|
||||
column_span: 2
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: select.samsung_ehssentinel_infsv3011
|
||||
secondary_info: last-updated
|
||||
name: DHW Application
|
||||
icon: mdi:water-boiler
|
||||
- entity: number.samsung_ehssentinel_infsv3021
|
||||
secondary_info: last-updated
|
||||
name: Heat Pump Max. Temperature
|
||||
- entity: number.samsung_ehssentinel_infsv3022
|
||||
name: Heat Pump Stop
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3023
|
||||
name: Heat Pump Start
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3024
|
||||
name: Heat Pump Min. Space heating operation time
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3025
|
||||
name: Heat Pump Max. DHW operation time
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3026
|
||||
name: Heat Pump Max. Space heating operation time
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3032
|
||||
name: Booster Heat Delay Time
|
||||
secondary_info: last-updated
|
||||
- entity: switch.samsung_ehssentinel_infsv3041
|
||||
name: Disinfection Application
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv3042
|
||||
name: Disinfection Interval
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3043
|
||||
name: Disinfection Start Time
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3044
|
||||
name: Disinfection Target Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3045
|
||||
name: Disinfection Duration
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3046
|
||||
name: Disinfection Max Time
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv3051
|
||||
name: Forced DHW Operation Time OFF Function
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3052
|
||||
name: Farced DHW Operation Time Duration
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv3061
|
||||
name: Solar Panel/DHW Thermostat H/P Combination
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv3071
|
||||
name: Direction of 3Way Valve DHW Tank
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3081
|
||||
name: Energy Metering BUH 1 step capacity
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3082
|
||||
name: Energy Metering BUH 2 step capacity
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv3083
|
||||
name: Energy Metering BSH capacity
|
||||
secondary_info: last-updated
|
||||
title: FSV 30** - DHW code
|
||||
grid_options:
|
||||
columns: full
|
||||
column_span: 2
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: select.samsung_ehssentinel_infsv4011
|
||||
secondary_info: last-updated
|
||||
name: Heat Pump Heating/DHW Priority
|
||||
- entity: number.samsung_ehssentinel_infsv4012
|
||||
name: Heat Pump Outdoor Temp. for Priority
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4013
|
||||
name: Heat Pump Heat OFF
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4021
|
||||
name: Backup Heater Application
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4022
|
||||
name: Backup Heater BUH/BSH Priority
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4023
|
||||
name: Backup Heater Cold Weather Compensation
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4024
|
||||
name: Backup Heater Threshold Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4025
|
||||
name: Backup Heater Defrost Backup Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4031
|
||||
name: Backup Boiler Application
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4032
|
||||
name: Backup Boiler Boiler Priority
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4033
|
||||
name: Backup Boiler Threshold Power
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4041
|
||||
name: Mixing Valve Application
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4042
|
||||
name: Mixing Valve Target △T (Heating)
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4043
|
||||
secondary_info: last-updated
|
||||
name: Mixing Valve Target △T (Cooling)
|
||||
- entity: select.samsung_ehssentinel_infsv4044
|
||||
name: Mixing Valve Control Factor
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4045
|
||||
name: Mixing Valve Control Factor
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4046
|
||||
name: Mixing Valve Running Time
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4051
|
||||
name: Inverter Pump Application
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv4052
|
||||
name: Inverter Pump Target △T
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4053
|
||||
name: Inverter Pump Control Factor
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv4061
|
||||
name: Zone Control Application
|
||||
secondary_info: last-updated
|
||||
title: FSV 40** - Heating code
|
||||
state_color: false
|
||||
grid_options:
|
||||
columns: full
|
||||
column_span: 2
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: number.samsung_ehssentinel_infsv5011
|
||||
secondary_info: last-updated
|
||||
name: Outing Mode Water Out Temp. for Cooling
|
||||
- entity: number.samsung_ehssentinel_infsv5012
|
||||
name: Outing Mode Room Temp. for Cooling
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5013
|
||||
name: Outing Mode Water Out Temp. for Heating
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5014
|
||||
name: Outing Mode Room Temp. for Heating
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5015
|
||||
name: Outing Mode Auto Cooling WL1 Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5016
|
||||
name: Outing Mode Auto Cooling WL2 Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5017
|
||||
name: Outing Mode Auto Heating WL1 Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5018
|
||||
name: Outing Mode Auto Heating WL2 Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5019
|
||||
name: Outing Mode Target Tank Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5021
|
||||
name: DHW Saving Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: switch.samsung_ehssentinel_infsv5022
|
||||
name: DHW Saving Mode
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5023
|
||||
name: DHW Saving Thermo on Temp.
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv5041
|
||||
name: Power Peak Control Application
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv5042
|
||||
name: Power Peak Control Select Forced Off Parts
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv5043
|
||||
name: Power Peak Control Using Input Voltage
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv5051
|
||||
name: Frequency Ratio Control
|
||||
- entity: select.samsung_ehssentinel_infsv5061
|
||||
name: Ratio of hot water supply compare to heating
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv5081
|
||||
name: PV Control Application
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5082
|
||||
name: PV Control Setting Temp. Shift Value (Cool)
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5083
|
||||
name: PV Control Setting Temp. Shift Value (Heat)
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv5091
|
||||
name: Smart Grid Control Application
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5092
|
||||
name: Smart Grid Control Setting Temp. Shift Value (Heat)
|
||||
secondary_info: last-updated
|
||||
- entity: number.samsung_ehssentinel_infsv5093
|
||||
name: Smart Grid Control Setting Temp. Shift Value (DHW)
|
||||
secondary_info: last-updated
|
||||
- entity: select.samsung_ehssentinel_infsv5094
|
||||
name: Smart Grid Control DHW Mode
|
||||
secondary_info: last-updated
|
||||
title: FSV 50** - Others code
|
||||
grid_options:
|
||||
columns: full
|
||||
column_span: 2
|
||||
cards: []
|
||||
dense_section_placement: true
|
||||
@@ -6,38 +6,23 @@ views:
|
||||
sections:
|
||||
- type: grid
|
||||
cards:
|
||||
- type: tile
|
||||
name: Operation mode
|
||||
vertical: true
|
||||
hide_state: false
|
||||
show_entity_picture: false
|
||||
grid_options:
|
||||
columns: 6
|
||||
rows: 2
|
||||
entity: sensor.samsung_ehssentinel_outdooroperationstatus
|
||||
- type: tile
|
||||
entity: binary_sensor.samsung_ehssentinel_controlsilence
|
||||
name: Silent Mode
|
||||
vertical: true
|
||||
hide_state: false
|
||||
show_entity_picture: false
|
||||
- type: tile
|
||||
name: DHW Power
|
||||
vertical: true
|
||||
hide_state: false
|
||||
show_entity_picture: false
|
||||
entity: binary_sensor.samsung_ehssentinel_dhwpower
|
||||
grid_options:
|
||||
columns: 6
|
||||
rows: 2
|
||||
- type: tile
|
||||
name: Defrost Status
|
||||
vertical: true
|
||||
hide_state: false
|
||||
show_entity_picture: false
|
||||
entity: sensor.samsung_ehssentinel_outdoordefroststep
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: sensor.samsung_ehssentinel_power
|
||||
name: Heatpump Power
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_outdooroperationstatus
|
||||
name: Operation Mode
|
||||
secondary_info: last-updated
|
||||
- entity: binary_sensor.samsung_ehssentinel_dhwpower
|
||||
name: DHW Power
|
||||
secondary_info: last-updated
|
||||
- entity: binary_sensor.samsung_ehssentinel_controlsilence
|
||||
name: Silence Mode
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_outdoordefroststep
|
||||
name: Defrost Step
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_ehssentinelheatoutput
|
||||
name: Heat Output
|
||||
secondary_info: last-updated
|
||||
@@ -45,6 +30,9 @@ views:
|
||||
- entity: sensor.samsung_ehssentinel_ingeneratedpowerlastminute
|
||||
name: Generated Power Last Minute
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_inflowsensorcalc
|
||||
secondary_info: last-changed
|
||||
name: Water Flow Speed
|
||||
- entity: sensor.samsung_ehssentinel_ehssentinelcop
|
||||
name: COP
|
||||
secondary_info: last-updated
|
||||
@@ -64,9 +52,15 @@ views:
|
||||
secondary_info: last-updated
|
||||
name: Outdoor Temperatur
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcomp1targethz
|
||||
name: Compressor Frequence
|
||||
name: Compressor Target Frequence
|
||||
secondary_info: last-updated
|
||||
icon: mdi:sine-wave
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcomp1runhz
|
||||
name: Compressor Run Frequence
|
||||
secondary_info: last-updated
|
||||
- entity: sensor.samsung_ehssentinel_outdoorcomp1orderhz
|
||||
name: Compressor Order Frequence
|
||||
secondary_info: last-updated
|
||||
title: Current Data
|
||||
- type: entities
|
||||
entities:
|
||||
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 445 KiB |
BIN
ressources/images/dashboard_cm1.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
ressources/images/dashboard_cm2.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
ressources/images/dashboard_cm3.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
@@ -3,6 +3,7 @@ import serial
|
||||
import serial_asyncio
|
||||
import traceback
|
||||
from MessageProcessor import MessageProcessor
|
||||
from MessageProducer import MessageProducer
|
||||
from EHSArguments import EHSArguments
|
||||
from EHSConfig import EHSConfig
|
||||
from EHSExceptions import MessageWarningException, SkipInvalidPacketException
|
||||
@@ -16,7 +17,7 @@ from CustomLogger import logger
|
||||
from NASAPacket import NASAPacket, AddressClassEnum, PacketType, DataType
|
||||
from NASAMessage import NASAMessage
|
||||
|
||||
version = "0.3.0 Stable"
|
||||
version = "1.0.0 Stable"
|
||||
|
||||
async def main():
|
||||
"""
|
||||
@@ -76,28 +77,6 @@ async def main():
|
||||
await serial_connection(config, args)
|
||||
|
||||
async def process_buffer(buffer, args, config):
|
||||
"""
|
||||
Processes a buffer of data asynchronously, identifying and handling packets based on specific criteria.
|
||||
Args:
|
||||
buffer (list): A list of bytes representing the buffer to be processed.
|
||||
args (Any): Additional arguments to be passed to the packet processing function.
|
||||
Notes:
|
||||
- The function continuously checks the buffer for data.
|
||||
- If the first byte of the buffer is 0x32, it is considered a start byte.
|
||||
- The packet size is determined by combining the second and third bytes of the buffer.
|
||||
- If the buffer contains enough data for a complete packet, the packet is processed.
|
||||
- If the buffer does not contain enough data, the function waits and checks again.
|
||||
- Non-start bytes are removed from the buffer.
|
||||
- The function sleeps for 0.03 seconds between iterations to avoid busy-waiting.
|
||||
Logging:
|
||||
- Logs the buffer size when data is present.
|
||||
- Logs when the start byte is recognized.
|
||||
- Logs the calculated packet size.
|
||||
- Logs the complete packet and the last byte read when a packet is processed.
|
||||
- Logs if the buffer is too small to read a complete packet.
|
||||
- Logs if a received byte is not a start byte.
|
||||
"""
|
||||
|
||||
if buffer:
|
||||
if (len(buffer) > 14):
|
||||
for i in range(0, len(buffer)):
|
||||
@@ -111,18 +90,6 @@ async def process_buffer(buffer, args, config):
|
||||
logger.debug(f"Buffer to short for NASA {len(buffer)}")
|
||||
|
||||
async def serial_connection(config, args):
|
||||
"""
|
||||
Asynchronously reads data from a serial connection and processes it.
|
||||
Args:
|
||||
config (object): Configuration object containing serial or tcp connection parameters.
|
||||
args (object): Additional arguments for buffer processing.
|
||||
This function establishes a serial or tcp connection using parameters from the config object,
|
||||
reads data from the serial port or tcp port until a specified delimiter (0x34) is encountered,
|
||||
and appends the received data to a buffer. It also starts an asynchronous task to
|
||||
process the buffer.
|
||||
The function runs an infinite loop to continuously read data from the serial port/tcp port.
|
||||
"""
|
||||
|
||||
buffer = []
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -139,109 +106,92 @@ async def serial_connection(config, args):
|
||||
rtscts=True,
|
||||
timeout=1
|
||||
)
|
||||
|
||||
|
||||
await asyncio.gather(
|
||||
serial_read(reader, args, config),
|
||||
serial_write(writer, reader, args, config),
|
||||
serial_write(writer, config),
|
||||
)
|
||||
|
||||
async def serial_read(reader: asyncio.StreamReader, args, config):
|
||||
prev_byte = 0x00
|
||||
packet_started = False
|
||||
data = bytearray()
|
||||
packet_size = 0
|
||||
|
||||
async def serial_read(reader, args, config):
|
||||
while True:
|
||||
data = await reader.readuntil(b'\x34') # Read up to end of next message 0x34
|
||||
if data:
|
||||
asyncio.create_task(process_buffer(data, args, config))
|
||||
#buffer.extend(data)
|
||||
logger.debug(f"Received: {data}")
|
||||
logger.debug(f"Received: {data!r}")
|
||||
logger.debug(f"Received: {[hex(x) for x in data]}")
|
||||
current_byte = await reader.read(1) # read bitewise
|
||||
#data = await reader.read(1024)
|
||||
#data = await reader.readuntil(b'\x34fd')
|
||||
if current_byte:
|
||||
if packet_started:
|
||||
data.extend(current_byte)
|
||||
if len(data) == 3:
|
||||
packet_size = ((data[1] << 8) | data[2]) + 2
|
||||
|
||||
if packet_size <= len(data):
|
||||
if current_byte == b'\x34':
|
||||
asyncio.create_task(process_buffer(data, args, config))
|
||||
logger.debug(f"Received int: {data}")
|
||||
logger.debug(f"Received hex: {[hex(x) for x in data]}")
|
||||
data = bytearray()
|
||||
packet_started = False
|
||||
else:
|
||||
if config.LOGGING['invalidPacket']:
|
||||
logger.warning(f"Packet does not end with an x34. Size {packet_size} length {len(data)}")
|
||||
logger.warning(f"Received hex: {[hex(x) for x in data]}")
|
||||
logger.warning(f"Received raw: {data}")
|
||||
else:
|
||||
logger.debug(f"Packet does not end with an x34. Size {packet_size} length {len(data)}")
|
||||
logger.debug(f"Received hex: {[hex(x) for x in data]}")
|
||||
logger.debug(f"Received raw: {data}")
|
||||
|
||||
data = bytearray()
|
||||
packet_started = False
|
||||
|
||||
await asyncio.sleep(0.1) # Yield control to other tasks
|
||||
# identify packet start
|
||||
if current_byte == b'\x00' and prev_byte == b'\x32':
|
||||
packet_started = True
|
||||
data.extend(prev_byte)
|
||||
data.extend(current_byte)
|
||||
|
||||
async def serial_write(writer:asyncio.StreamWriter, reader: asyncio.StreamReader, args, config):
|
||||
"""
|
||||
TODO Not used yet, only for future use...
|
||||
prev_byte = current_byte
|
||||
|
||||
#await asyncio.sleep(0.001) # Yield control to other tasks
|
||||
|
||||
|
||||
Asynchronously writes data to the serial port.
|
||||
This function sends data through the serial port at regular intervals.
|
||||
Args:
|
||||
transport: The serial transport object.
|
||||
args: Additional arguments.
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
async def serial_write(writer:asyncio.StreamWriter, config):
|
||||
producer = MessageProducer(writer=writer)
|
||||
|
||||
# Wait 20s befor initial polling
|
||||
await asyncio.sleep(20)
|
||||
|
||||
if config.POLLING is not None:
|
||||
for poller in config.POLLING['fetch_interval']:
|
||||
if poller['enable']:
|
||||
await asyncio.sleep(3)
|
||||
asyncio.create_task(make_default_request_packet(writer=writer, config=config, poller=poller))
|
||||
await asyncio.sleep(1)
|
||||
asyncio.create_task(make_default_request_packet(producer=producer, config=config, poller=poller))
|
||||
|
||||
async def make_default_request_packet(writer, config, poller):
|
||||
async def make_default_request_packet(producer: MessageProducer, config: EHSConfig, poller):
|
||||
logger.info(f"Setting up Poller {poller['name']} every {poller['schedule']} seconds")
|
||||
message_list = []
|
||||
for message in config.POLLING['groups'][poller['name']]:
|
||||
tmp_msg = NASAMessage()
|
||||
tmp_msg.set_packet_message(int(config.NASA_REPO[message]['address'], 16))
|
||||
if config.NASA_REPO[message]['type'] == 'ENUM':
|
||||
tmp_msg.set_packet_message_type(0)
|
||||
tmp_msg.set_packet_payload([0])
|
||||
elif config.NASA_REPO[message]['type'] == 'VAR':
|
||||
tmp_msg.set_packet_message_type(1)
|
||||
tmp_msg.set_packet_payload([0, 0])
|
||||
elif config.NASA_REPO[message]['type'] == 'LVAR':
|
||||
tmp_msg.set_packet_message_type(2)
|
||||
tmp_msg.set_packet_payload([0, 0, 0, 0])
|
||||
else:
|
||||
logger.warning(f"Unknown Type for {message} type: {config.NASA_REPO[message]['type']}")
|
||||
break
|
||||
message_list.append(tmp_msg)
|
||||
message_list.append(message)
|
||||
|
||||
while True:
|
||||
chunk_size = 10
|
||||
chunks = [message_list[i:i + chunk_size] for i in range(0, len(message_list), chunk_size)]
|
||||
for chunk in chunks:
|
||||
await asyncio.sleep(1)
|
||||
nasa_msg = NASAPacket()
|
||||
nasa_msg.set_packet_source_address_class(AddressClassEnum.WiFiKit)
|
||||
nasa_msg.set_packet_source_channel(0)
|
||||
nasa_msg.set_packet_source_address(144)
|
||||
nasa_msg.set_packet_dest_address_class(AddressClassEnum.BroadcastSetLayer)
|
||||
nasa_msg.set_packet_dest_channel(0)
|
||||
nasa_msg.set_packet_dest_address(32)
|
||||
nasa_msg.set_packet_information(True)
|
||||
nasa_msg.set_packet_version(2)
|
||||
nasa_msg.set_packet_retry_count(0)
|
||||
nasa_msg.set_packet_type(PacketType.Normal)
|
||||
nasa_msg.set_packet_data_type(DataType.Read)
|
||||
nasa_msg.set_packet_number(len(chunk))
|
||||
nasa_msg.set_packet_messages(chunk)
|
||||
final_packet = nasa_msg.to_raw()
|
||||
writer.write(final_packet)
|
||||
await writer.drain()
|
||||
if config.LOGGING['pollerMessage']:
|
||||
logger.info(f"Polling following raw: {[hex(x) for x in final_packet]}")
|
||||
logger.info(f"Polling following NASAPacket: {nasa_msg}")
|
||||
else:
|
||||
logger.debug(f"Sent data raw: {final_packet}")
|
||||
logger.debug(f"Sent data raw: {nasa_msg}")
|
||||
logger.debug(f"Sent data raw: {[hex(x) for x in final_packet]}")
|
||||
logger.debug(f"Sent data raw: {[x for x in final_packet]}")
|
||||
|
||||
try:
|
||||
await producer.read_request(message_list)
|
||||
except MessageWarningException as e:
|
||||
logger.warning("Polling Messages was not successfull")
|
||||
logger.warning(f"Error processing message: {e}")
|
||||
logger.warning(f"Message List: {message_list}")
|
||||
except Exception as e:
|
||||
logger.error("Error Accured, Polling will be skipped")
|
||||
logger.error(f"Error processing message: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
await asyncio.sleep(poller['schedule'])
|
||||
logger.info(f"Refresh Poller {poller['name']}")
|
||||
|
||||
async def process_packet(buffer, args, config):
|
||||
"""
|
||||
Asynchronously processes a packet buffer.
|
||||
If `dumpWriter` is `None`, it attempts to process the packet using `MessageProcessor`.
|
||||
If a `MessageWarningException` is raised, it logs a warning and skips the packet.
|
||||
If any other exception is raised, it logs an error, skips the packet, and logs the stack trace.
|
||||
If `dumpWriter` is not `None`, it writes the buffer to `dumpWriter`.
|
||||
Args:
|
||||
buffer (bytes): The packet buffer to be processed.
|
||||
"""
|
||||
|
||||
if args.DUMPFILE and not args.DRYRUN:
|
||||
async with aiofiles.open(args.DUMPFILE, "a") as dumpWriter:
|
||||
await dumpWriter.write(f"{buffer}\n")
|
||||
@@ -254,7 +204,7 @@ async def process_packet(buffer, args, config):
|
||||
logger.debug(nasa_packet)
|
||||
if nasa_packet.packet_source_address_class in (AddressClassEnum.Outdoor, AddressClassEnum.Indoor):
|
||||
messageProcessor = MessageProcessor()
|
||||
messageProcessor.process_message(nasa_packet)
|
||||
await messageProcessor.process_message(nasa_packet)
|
||||
elif nasa_packet.packet_source_address_class == AddressClassEnum.WiFiKit and \
|
||||
nasa_packet.packet_dest_address_class == AddressClassEnum.BroadcastSelfLayer and \
|
||||
nasa_packet.packet_data_type == DataType.Notification:
|
||||
@@ -274,14 +224,17 @@ async def process_packet(buffer, args, config):
|
||||
logger.warning("Value Error on parsing Packet, Packet will be skipped")
|
||||
logger.warning(f"Error processing message: {e}")
|
||||
logger.warning(f"Complete Packet: {[hex(x) for x in buffer]}")
|
||||
logger.warning(traceback.format_exc())
|
||||
except SkipInvalidPacketException as e:
|
||||
logger.debug("Warnung accured, Packet will be skipped")
|
||||
logger.debug(f"Error processing message: {e}")
|
||||
logger.debug(f"Complete Packet: {[hex(x) for x in buffer]}")
|
||||
logger.debug(traceback.format_exc())
|
||||
except MessageWarningException as e:
|
||||
logger.warning("Warnung accured, Packet will be skipped")
|
||||
logger.warning(f"Error processing message: {e}")
|
||||
logger.warning(f"Complete Packet: {[hex(x) for x in buffer]}")
|
||||
logger.warning(traceback.format_exc())
|
||||
except Exception as e:
|
||||
logger.error("Error Accured, Packet will be skipped")
|
||||
logger.error(f"Error processing message: {e}")
|
||||
|
||||