Skip to content

Commit 6487c8e

Browse files
committed
fix: more robust tcp connection and better logging
1 parent cf8bf3c commit 6487c8e

5 files changed

Lines changed: 127 additions & 20 deletions

File tree

.vscode/tasks.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
// See https://go.microsoft.com/fwlink/?LinkId=733558
3+
// for the documentation about the tasks.json format
4+
"version": "2.0.0",
5+
"tasks": [
6+
{
7+
"label": "run main program",
8+
"type": "shell",
9+
"command": "python3 main.py"
10+
}
11+
]
12+
}

main.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,15 @@ def initialize_logging(log_level_str: str):
3434

3535
def message_callback(message: DecodedMessage):
3636
"""Callback-Funktion, die aufgerufen wird, wenn eine Nachricht dekodiert wurde."""
37-
print("\n" + "="*50)
38-
print(f"NEUE NACHRICHT EMPFANGEN (Protokoll-ID: {message.protocol_id})")
39-
model = message.metadata.get("model", "Unbekannt")
40-
print(f"Modell: {model}")
41-
print(f"Payload: {message.payload}")
42-
print("-" * 20)
43-
print("Alle Felder:")
44-
# Zeige Metadaten an
45-
for key, value in message.metadata.items():
46-
print(f" {key}: {value}")
47-
48-
# Zeige RawFrame-Infos an, falls vorhanden
37+
model = message.metadata.get("model", "Unknown")
38+
logger.info(
39+
f"Decoded message received: protocol={message.protocol_id}, "
40+
f"model={model}, "
41+
f"payload={message.payload}"
42+
)
43+
logger.debug(f"Full Metadata: {message.metadata}")
4944
if message.raw:
50-
print(" Raw Frame Info:")
51-
print(f" Line: {message.raw.line}")
52-
print(f" Timestamp: {message.raw.timestamp}")
53-
if message.raw.rssi:
54-
print(f" RSSI: {message.raw.rssi}")
55-
print("="*50 + "\n")
45+
logger.debug(f"Raw Frame: {message.raw}")
5646

5747
def main():
5848
# .env-Datei laden. Umgebungsvariablen werden gesetzt, aber CLI-Argumente überschreiben diese.

signalduino/controller.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def _reader_loop(self) -> None:
103103
try:
104104
line = self.transport.readline()
105105
if line:
106+
self.logger.debug("RX RAW: %r", line)
106107
self._raw_message_queue.put(line)
107108
except SignalduinoConnectionError as e:
108109
self.logger.error("Connection error in reader loop: %s", e)
@@ -157,6 +158,8 @@ def _writer_loop(self) -> None:
157158
self._send_and_wait(command)
158159
except queue.Empty:
159160
continue
161+
except SignalduinoCommandTimeout as e:
162+
self.logger.warning("Writer loop: %s", e)
160163
except Exception:
161164
if not self._stop_event.is_set():
162165
self.logger.exception("Unhandled exception in writer loop")
@@ -225,7 +228,7 @@ def set_message_type_enabled(
225228
raise ValueError(f"Invalid message type: {message_type}")
226229

227230
verb = "E" if enabled else "D"
228-
noun = message_type[1] # S, U, or C
231+
noun = message_type # S, U, or C
229232
command = f"C{verb}{noun}"
230233
self.send_command(command)
231234

@@ -299,7 +302,21 @@ def on_response(response: str):
299302
try:
300303
return response_queue.get(timeout=timeout)
301304
except queue.Empty:
302-
raise SignalduinoCommandTimeout(f"Command '{payload}' timed out")
305+
# Code Refactor: Distinguish between timeout (slow device) and dead connection.
306+
# The reader loop will set _stop_event and close the transport on SignalduinoConnectionError
307+
if self._stop_event.is_set() or not self.transport.is_open:
308+
self.logger.error(
309+
"Command '%s' timed out. Connection appears to be dead (transport closed or worker threads stopping).", payload
310+
)
311+
raise SignalduinoConnectionError(
312+
f"Command '{payload}' failed: Connection dropped."
313+
) from None
314+
315+
# If transport is still open and not stopping, assume it's a slow device/no response
316+
self.logger.warning(
317+
"Command '%s' timed out. Transport still appears open. Treating as no response from device.", payload
318+
)
319+
raise SignalduinoCommandTimeout(f"Command '{payload}' timed out") from None
303320

304321
def _handle_mqtt_command(self, command: str, payload: str) -> None:
305322
"""Handles commands received via MQTT."""

signalduino/transport.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
from __future__ import annotations
44

5+
import logging
56
import socket
67
from typing import Optional
78

89
from .exceptions import SignalduinoConnectionError
910

11+
logger = logging.getLogger(__name__)
12+
1013

1114
class BaseTransport:
1215
"""Minimal interface shared by all transports."""
@@ -126,6 +129,9 @@ def readline(self, timeout: Optional[float] = None) -> Optional[str]:
126129
except socket.timeout:
127130
return None
128131

132+
if chunk:
133+
logger.debug("TCP RECV CHUNK: %r", chunk)
134+
129135
if not chunk:
130136
raise SignalduinoConnectionError("Remote closed connection")
131137
self._buffer.extend(chunk)

tests/test_connection_drop.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import queue
2+
import threading
3+
import time
4+
import unittest
5+
from unittest.mock import MagicMock
6+
7+
from signalduino.controller import SignalduinoController
8+
from signalduino.exceptions import SignalduinoCommandTimeout, SignalduinoConnectionError
9+
from signalduino.transport import BaseTransport
10+
11+
class MockTransport(BaseTransport):
12+
def __init__(self):
13+
self.is_open_flag = False
14+
self.output_queue = queue.Queue()
15+
16+
def open(self):
17+
self.is_open_flag = True
18+
19+
def close(self):
20+
self.is_open_flag = False
21+
22+
@property
23+
def is_open(self):
24+
return self.is_open_flag
25+
26+
def write_line(self, data):
27+
if not self.is_open_flag:
28+
raise SignalduinoConnectionError("Closed")
29+
30+
def readline(self, timeout=None):
31+
if not self.is_open_flag:
32+
raise SignalduinoConnectionError("Closed")
33+
try:
34+
return self.output_queue.get(timeout=timeout or 0.1)
35+
except queue.Empty:
36+
return None
37+
38+
class TestConnectionDrop(unittest.TestCase):
39+
def test_timeout_normally(self):
40+
"""Test that a simple timeout raises SignalduinoCommandTimeout."""
41+
transport = MockTransport()
42+
controller = SignalduinoController(transport)
43+
controller.connect()
44+
45+
# Expect SignalduinoCommandTimeout because transport sends nothing
46+
with self.assertRaises(SignalduinoCommandTimeout):
47+
controller.send_command("V", expect_response=True, timeout=0.5)
48+
49+
controller.disconnect()
50+
51+
def test_connection_drop_during_command(self):
52+
"""Test that if connection dies during command wait, we get ConnectionError."""
53+
transport = MockTransport()
54+
controller = SignalduinoController(transport)
55+
controller.connect()
56+
57+
# We need to simulate the reader loop crashing or transport closing
58+
# signalduino controller checks transport.is_open or _stop_event
59+
60+
# Hook into write_line to close transport immediately after sending
61+
# simulating a crash right after send
62+
original_write = transport.write_line
63+
def side_effect(data):
64+
original_write(data)
65+
# Simulate connection loss
66+
transport.close()
67+
# Also set stop event as reader loop would
68+
controller._stop_event.set()
69+
70+
transport.write_line = side_effect
71+
72+
# Current behavior: Raises SignalduinoCommandTimeout because it just waits on queue
73+
# Desired behavior: Raises SignalduinoConnectionError because connection is dead
74+
75+
try:
76+
controller.send_command("V", expect_response=True, timeout=1.0)
77+
except Exception as e:
78+
print(f"Caught exception: {type(e).__name__}: {e}")
79+
# validating what it currently raises
80+
# self.assertIsInstance(e, SignalduinoConnectionError)
81+
82+
controller.disconnect()

0 commit comments

Comments
 (0)