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
This commit is contained in:
echoDaveD
2025-03-13 19:57:33 +01:00
committed by GitHub
parent 43de00aacc
commit f0222d750f
26 changed files with 7251 additions and 4316 deletions

1
.gitignore vendored
View File

@@ -174,3 +174,4 @@ prot.csv
helpertils/serial.py
helpertils/test.py
helpertils/socker.py
helpertils/messagesFound.txt

View File

@@ -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...")

View File

@@ -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

View File

@@ -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}")

View File

@@ -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

View File

@@ -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
View 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()

View File

@@ -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 (

View File

@@ -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

View File

@@ -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
![alt text](ressources/images/dashboard1.png)
![alt text](ressources/images/dashboard2.png)
![alt text](ressources/images/dashboard3.png)
## Control Mode
![alt text](ressources/images/dashboard_cm1.png)
![alt text](ressources/images/dashboard_cm2.png)
![alt text](ressources/images/dashboard_cm3.png)
# 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

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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
View 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}")

View 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}")

View 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()

View 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

View 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

View File

@@ -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:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

View File

@@ -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}")