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

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