From 502b79ed272ec0b0d8eb6ac51f7e61e1787248b0 Mon Sep 17 00:00:00 2001 From: Johannes Bauer Date: Sun, 12 Apr 2026 19:56:04 +0200 Subject: [PATCH 1/2] Fix web connection broken by E3DC server protocol update The E3DC server updated its WebSocket protocol in three ways that broke the web connection path: 1. The server now opens the handshake with RSCP_REQ_SET_PROTOCOL_VERSION before SERVER_REGISTER_CONNECTION. Without a reply the server sends SERVER_UNREGISTER_CONNECTION and drops the connection. Handle this message by echoing back RSCP_SET_PROTOCOL_VERSION with the same type and version value the server sent. 2. After the protocol-version exchange the server switches to a compact frame format (ctrl bit 1 set) that omits the 12-byte timestamp and uses a 32-bit length field, giving an 8-byte header instead of 18. rscpFrameDecode now detects ctrl & 0x02 and parses accordingly. 3. rscpDecode advanced past a decoded value using the struct format size rather than the length field from the RSCP header. These two values can differ (e.g. in the server's authentication-failure response), causing the parser to misalign and crash on the next tag. Fixed by using headerSize + length for advancement. --- e3dc/_e3dc_rscp_web.py | 8 ++++++-- e3dc/_rscpLib.py | 39 +++++++++++++++++++++++++++------------ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/e3dc/_e3dc_rscp_web.py b/e3dc/_e3dc_rscp_web.py index fc4e54d..62ac050 100644 --- a/e3dc/_e3dc_rscp_web.py +++ b/e3dc/_e3dc_rscp_web.py @@ -121,7 +121,7 @@ def __init__( REMOTE_ADDRESS, on_message=lambda _, msg: self.on_message(msg), on_close=lambda _ws, _, __: self.reset(), - on_error=lambda _ws, _: self.reset(), + on_error=lambda _ws, _: self.reset() ) self.reset() @@ -260,7 +260,11 @@ def on_message(self, message: bytes): raise # print "Decoded received message", decodedMsg - if tag == RscpTag.SERVER_REQ_PING: + if tag == RscpTag.RSCP_REQ_SET_PROTOCOL_VERSION: + reply = rscpFrame(rscpEncode(RscpTag.RSCP_SET_PROTOCOL_VERSION, decodedMsg[1], decodedMsg[2])) + self.ws.send(reply, ABNF.OPCODE_BINARY) + return + elif tag == RscpTag.SERVER_REQ_PING: pingFrame = rscpFrame( rscpEncode(RscpTag.SERVER_PING, RscpType.NoneType, None) ) diff --git a/e3dc/_rscpLib.py b/e3dc/_rscpLib.py index 29501a3..b0f2990 100644 --- a/e3dc/_rscpLib.py +++ b/e3dc/_rscpLib.py @@ -207,27 +207,43 @@ def rscpFrame(data: bytes) -> bytes: def rscpFrameDecode(frameData: bytes, returnFrameLen: bool = False): """Decodes RSCP Frame.""" - headerFmt = " Date: Mon, 13 Apr 2026 07:16:55 +0000 Subject: [PATCH 2/2] Add logging and fix web connection handling Replace the DEBUG_DICT print-based debug mechanism in _rscpLib.py with proper Python logging (logger.debug/warning) so that callers can control output via the standard logging framework. Add logging to _e3dc_rscp_web.py and _e3dc.py for connection lifecycle events, timeouts, and retries. Sensitive fields (SERVER_USER, SERVER_PASSWD) are redacted in debug output. Fix two further web connection issues discovered with the new logging: 1. SERVER_UNREGISTER_CONNECTION was handled by calling self.disconnect(), but the server now sends this message as part of the normal virtual connection handshake (to close the initial connection before registering the virtual one). Removing the disconnect() call allows the subsequent SERVER_REGISTER_CONNECTION to be received. 2. When authentication fails the server returns virtConId=-1. Previously this caused isConnected() to return True (since -1 is not None), and every subsequent request would silently time out. Now virtConId=-1 is detected, logged as an error, and left as None so the connection attempt fails immediately with RequestTimeoutError. Co-Authored-By: Claude Sonnet 4.6 --- e3dc/_e3dc.py | 4 ++++ e3dc/_e3dc_rscp_web.py | 18 +++++++++++++++--- e3dc/_rscpLib.py | 11 +++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/e3dc/_e3dc.py b/e3dc/_e3dc.py index 8aac428..5a2189a 100644 --- a/e3dc/_e3dc.py +++ b/e3dc/_e3dc.py @@ -5,6 +5,7 @@ # Licensed under a MIT license. See LICENSE for details import datetime import hashlib +import logging import struct import time import uuid @@ -21,6 +22,8 @@ from ._rscpLib import rscpFindTag, rscpFindTagIndex from ._rscpTags import RscpTag, RscpType, getStrPowermeterType, getStrPviType +logger = logging.getLogger(__name__) + REMOTE_ADDRESS = "https://s10.e3dc.com/s10/phpcmd/cmd.php" REQUEST_INTERVAL_SEC = 10 # minimum interval between requests REQUEST_INTERVAL_SEC_LOCAL = 1 # minimum interval between requests @@ -238,6 +241,7 @@ def sendRequest( retry += 1 if retry > retries: raise SendError("Max retries reached") + logger.warning("Request failed, retrying (%d/%d)", retry, retries, exc_info=True) if not keepAlive: self.rscp.disconnect() diff --git a/e3dc/_e3dc_rscp_web.py b/e3dc/_e3dc_rscp_web.py index 62ac050..68f9c4c 100644 --- a/e3dc/_e3dc_rscp_web.py +++ b/e3dc/_e3dc_rscp_web.py @@ -5,6 +5,7 @@ # Licensed under a MIT license. See LICENSE for details import datetime import hashlib +import logging import struct import threading import time @@ -24,6 +25,8 @@ ) from ._rscpTags import RscpTag, RscpType, getRscpTag +logger = logging.getLogger(__name__) + """ The connection works the following way: (> outgoing, < incoming) @@ -121,7 +124,7 @@ def __init__( REMOTE_ADDRESS, on_message=lambda _, msg: self.on_message(msg), on_close=lambda _ws, _, __: self.reset(), - on_error=lambda _ws, _: self.reset() + on_error=lambda _ws, err: (logger.warning("WebSocket error: %s", err) or self.reset()) ) self.reset() @@ -139,6 +142,7 @@ def reset(self): def buildVirtualConn(self): """Method to create Virtual Connection.""" + logger.debug("Requesting virtual connection for %s", self.serialNumberWithPrefix) virtualConn = rscpFrame( rscpEncode( RscpTag.SERVER_REQ_NEW_VIRTUAL_CONNECTION, @@ -230,9 +234,15 @@ def registerConnectionHandler(self, decodedMsg: RscpMessage): if self.conId == 0: self.conId = rscpFindTagIndex(decodedMsg, RscpTag.SERVER_CONNECTION_ID) self.authLevel = rscpFindTagIndex(decodedMsg, RscpTag.SERVER_AUTH_LEVEL) + logger.debug("Initial connection registered: conId=%s authLevel=%s", self.conId, self.authLevel) else: self.virtConId = rscpFindTagIndex(decodedMsg, RscpTag.SERVER_CONNECTION_ID) self.virtAuthLevel = rscpFindTagIndex(decodedMsg, RscpTag.SERVER_AUTH_LEVEL) + if self.virtConId == -1: + logger.error("Authentication failed: server rejected credentials") + self.virtConId = None + else: + logger.debug("Virtual connection registered: virtConId=%s virtAuthLevel=%s", self.virtConId, self.virtAuthLevel) # reply = rscpFrame(rscpEncode(RscpTag.SERVER_CONNECTION_REGISTERED, RscpType.Container, [decodedMsg[2][0], decodedMsg[2][1]])); reply = rscpFrame( rscpEncode( @@ -261,6 +271,7 @@ def on_message(self, message: bytes): # print "Decoded received message", decodedMsg if tag == RscpTag.RSCP_REQ_SET_PROTOCOL_VERSION: + logger.debug("Protocol version request: v%s, acknowledging", decodedMsg[2]) reply = rscpFrame(rscpEncode(RscpTag.RSCP_SET_PROTOCOL_VERSION, decodedMsg[1], decodedMsg[2])) self.ws.send(reply, ABNF.OPCODE_BINARY) return @@ -276,8 +287,7 @@ def on_message(self, message: bytes): elif tag == RscpTag.SERVER_REGISTER_CONNECTION: self.registerConnectionHandler(decodedMsg) elif tag == RscpTag.SERVER_UNREGISTER_CONNECTION: - # this signifies some error - self.disconnect() + logger.warning("Server unregistered connection") elif tag == RscpTag.SERVER_REQ_RSCP_CMD: data = rscpFrameDecode( rscpFindTagIndex(decodedMsg, RscpTag.SERVER_RSCP_DATA) @@ -334,6 +344,7 @@ def sendRequest(self, message: RscpMessage) -> RscpMessage: break time.sleep(0.1) if not self.responseCallbackCalled: + logger.warning("Request timed out after %s seconds", self.TIMEOUT) raise RequestTimeoutError return self.requestResult @@ -402,6 +413,7 @@ def connect(self): break time.sleep(0.1) if not self.isConnected(): + logger.warning("Connection timed out after %s seconds", self.TIMEOUT) raise RequestTimeoutError def disconnect(self): diff --git a/e3dc/_rscpLib.py b/e3dc/_rscpLib.py index b0f2990..fa9b598 100644 --- a/e3dc/_rscpLib.py +++ b/e3dc/_rscpLib.py @@ -4,6 +4,7 @@ # Copyright 2017 Francesco Santini # Licensed under a MIT license. See LICENSE for details +import logging import math import struct import time @@ -24,6 +25,8 @@ # Type alias for RSCP messages RscpMessage: TypeAlias = tuple[str | int | RscpTag, str | int | RscpType, Any] +logger = logging.getLogger(__name__) + DEBUG_DICT = {"print_rscp": False} @@ -37,6 +40,7 @@ def set_debug(debug: bool): Nothing """ DEBUG_DICT["print_rscp"] = debug + logger.setLevel(logging.DEBUG if debug else logging.WARNING) packFmtDict_FixedSize = { @@ -144,8 +148,8 @@ def rscpEncode( rscptypeHex = getHexRscpType(rscptype) rscptype = getRscpType(rscptype) - if DEBUG_DICT["print_rscp"]: - print(">", tag, rscptype, data) + loggable_data = '' if tag in (RscpTag.SERVER_PASSWD, RscpTag.SERVER_USER ) else data + logger.debug("> %s %s %s", tag, rscptype, loggable_data) if isinstance(data, str): data = data.encode("utf-8") @@ -320,7 +324,6 @@ def rscpDecode( # ignore none utf-8 bytes val = val.decode("utf-8", "ignore") - if DEBUG_DICT["print_rscp"]: - print("<", strTag, strType, val) + logger.debug("< %s %s %s", strTag, strType, val) return (strTag, strType, val), headerSize + length