Skip to content

Commit 1942951

Browse files
author
sidey79
committed
feat: fix mqtt timeouts by improving response parsing for cc1101 and config commands (ADR-004)
1 parent 56ca564 commit 1942951

7 files changed

Lines changed: 452 additions & 77 deletions

File tree

docs/01_user_guide/mqtt_api.adoc

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,47 @@ Eine erfolgreiche Response auf `signalduino/v1/responses` hat folgende Struktur:
6565
}
6666
----
6767

68+
[[_cli_tool]]
69+
== CLI Tool zur Steuerung (`tools/sd_mqtt_cli.py`)
70+
71+
Das Skript `tools/sd_mqtt_cli.py` dient als einfaches Python-Kommandozeilen-Tool, um Befehle an das PySignalduino MQTT-Gateway zu senden und die Antworten zu empfangen.
72+
73+
=== Installation und Ausführung
74+
75+
Das Tool benötigt die `paho-mqtt` Abhängigkeit, die in der `requirements-dev.txt` enthalten ist.
76+
77+
[source,bash]
78+
----
79+
pip install paho-mqtt
80+
python3 tools/sd_mqtt_cli.py --help
81+
----
82+
83+
=== Verfügbare Kommandos
84+
85+
|===
86+
| Kommando | Beschreibung | Beispiel
87+
88+
| `reset`
89+
| Führt einen Factory Reset durch (`set/factory_reset`).
90+
| `python3 tools/sd_mqtt_cli.py reset`
91+
92+
| `get all-settings`
93+
| Fragt alle wichtigen CC1101-Einstellungen in einer aggregierten Nachricht ab.
94+
| `python3 tools/sd_mqtt_cli.py get all-settings`
95+
96+
| `get hardware-status --parameter <param>`
97+
| Fragt einen spezifischen CC1101-Parameter ab. Parameter: `frequency`, `bandwidth`, `rampl`, `sensitivity`, `datarate`.
98+
| `python3 tools/sd_mqtt_cli.py get hardware-status --parameter frequency`
99+
100+
| `get system-status --parameter <param>`
101+
| **NEU:** Fragt einen spezifischen System-Parameter ab. Parameter: `version`, `freeram`, `uptime`.
102+
| `python3 tools/sd_mqtt_cli.py get system-status --parameter freeram`
103+
104+
| `poll`
105+
| **NEU:** Fragt nacheinander alle verfügbaren System- und CC1101-Parameter ab. Nützlich zur Diagnose des aktuellen Gerätezustands.
106+
| `python3 tools/sd_mqtt_cli.py poll`
107+
|===
108+
68109
[[_get_commands]]
69110
== GET Commands (Status und Konfiguration abrufen)
70111

@@ -79,20 +120,20 @@ GET-Befehle benötigen eine leere Payload (`{}`) oder nur eine `req_id`.
79120
| Firmware-Version.
80121

81122
| `get/system/freeram`
82-
| `"1234"`
83-
| Verfügbarer RAM-Speicher.
123+
| `1234`
124+
| Verfügbarer RAM-Speicher (`int`).
84125

85126
| `get/system/uptime`
86-
| `"56789"`
87-
| System-Laufzeit.
127+
| `56789`
128+
| System-Laufzeit (`int`).
88129

89130
| `get/config/decoder`
90-
| `"MS=1;MU=1;MC=1;MN=1"`
91-
| Aktuelle Decoder-Konfiguration (aktivierte Protokollfamilien).
131+
| `{"MS": 1, "MU": 1, "MC": 1, "MN": 1}`
132+
| Aktuelle Decoder-Konfiguration (aktivierte Protokollfamilien) als geparstes Dictionary.
92133

93134
| `get/cc1101/config`
94-
| `"C0D11=0F"`
95-
| CC1101 Konfigurationsregister-Dump.
135+
| `{"cc1101_config_string": "C0D11=0F"}`
136+
| CC1101 Konfigurationsregister-Dump als gekapselter String.
96137

97138
| `get/cc1101/patable`
98139
| `"C3E = C0 C1 C2 C3 C4 C5 C6 C7"`
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
= ADR-004: Strukturiertes Parsing serieller Antworten für MQTT GET-Befehle
2+
:revdate: 2026-01-06
3+
:author: Roo
4+
5+
== 1. Kontext
6+
7+
Die MQTT-Befehle `get/cc1101/*` (z.B. `get/cc1101/config`) und `get/config/decoder` schlagen mit Timeouts fehl, obwohl die serielle Kommunikation mit der SIGNALDuino-Firmware die Antworten empfängt. Die Ursache liegt darin, dass der `MqttCommandDispatcher` eine strukturierte JSON-Payload (ein Python-Dictionary) als `data`-Feld in der MQTT-Antwort erwartet. Die zugrundeliegenden `SignalduinoCommands` Methoden geben jedoch in diesen Fällen den *rohen* String der seriellen Firmware-Antwort zurück.
8+
9+
Der `MqttCommandDispatcher` kann diese String-Antworten nicht direkt in das JSON-Antwortformat umwandeln, was zu einem Abbruch der Verarbeitung und damit zum Timeout führt.
10+
11+
Betroffene Befehle und ihre Rohantwortformate:
12+
* `get/config/decoder` (CG): `MS=1;MU=1;MC=1;Mred=1\n`
13+
* `get/cc1101/config` (C0DnF): `C0Dn11=<Hex-Wert>\n`
14+
15+
Zusätzlich müssen alle `get` Befehle, die einen rohen String zurückgeben, angepasst werden, um die Konsistenz des MQTT-API zu gewährleisten.
16+
17+
== 2. Entscheidung
18+
19+
Wir werden die `SignalduinoCommands` Methoden, die serielle GET-Befehle ausführen, so modifizieren, dass sie die rohe Firmware-Antwort parsen und ein konsistentes Python-Dictionary (`Dict[str, Any]`) zurückgeben. Dieses Dictionary wird dann vom `MqttCommandDispatcher` als JSON-Payload im `data`-Feld der MQTT-Antwort verwendet.
20+
21+
Dies stellt sicher, dass alle erfolgreichen `GET` Anfragen über MQTT eine strukturierte und maschinenlesbare JSON-Antwort erhalten und die Timeouts vermieden werden.
22+
23+
=== Detaillierte Logik-Anpassungen
24+
25+
1. **`get_config` (CG):**
26+
* Wird eine private Hilfsfunktion `_parse_decoder_config(response: str) -> Dict[str, int]` in [`signalduino/commands.py`](signalduino/commands.py) implementiert.
27+
* Diese Funktion parst den `key=value;` String in ein Dictionary (z.B. `{'MS': 1, 'MU': 1, 'MC': 1, 'Mred': 1}`).
28+
* Der Rückgabetyp von `get_config` wird von `str` auf `Dict[str, int]` geändert.
29+
30+
2. **`get_ccconf` (C0DnF):**
31+
* Diese Methode gibt einen String wie `C0Dn11=<Hex-Wert>` zurück.
32+
* Die Methode wird angepasst, um die rohe String-Antwort in ein Dictionary zu kapseln, z.B. `{'cc1101_config_string': response_string}`.
33+
* Der Rückgabetyp von `get_ccconf` wird von `str` auf `Dict[str, str]` geändert.
34+
35+
3. **Weitere einfache GET-Befehle:**
36+
* Methoden wie `get_version`, `get_free_ram`, `get_uptime` geben bereits einen geparsten Wert zurück (String oder Int), der korrekt gekapselt wird. Diese Methoden bleiben unverändert, da sie bereits einen strukturierten Wert zurückgeben, der indirekt im `data`-Feld des MQTT-Payloads landet.
37+
38+
== 3. Konsequenzen
39+
40+
=== Positive
41+
* **Behebung der Timeouts:** Die MQTT GET-Befehle für Konfigurationen werden korrekt beantwortet und die Timeouts behoben.
42+
* **API-Konsistenz:** Alle MQTT `GET` Antworten liefern nun eine konsistente, JSON-serialisierbare Struktur.
43+
* **Wartbarkeit:** Der Code wird robuster, da das Parsing der seriellen Antwort in der `commands.py`-Schicht zentralisiert ist.
44+
45+
=== Negative
46+
* **Refactoring:** Es müssen kleinere Refactorings in [`signalduino/commands.py`](signalduino/commands.py) durchgeführt werden, um die Rückgabetypen der Methoden anzupassen.
47+
* **Tests/Dokumentation:** Die zugehörigen Unittests in [`tests/test_mqtt_commands.py`](tests/test_mqtt_commands.py) und die MQTT API Dokumentation in [`docs/01_user_guide/mqtt_api.adoc`](docs/01_user_guide/mqtt_api.adoc) müssen aktualisiert werden.
48+
49+
== 4. Alternativen
50+
51+
1. **Alternative 1: Parsing im `MqttCommandDispatcher`:** Die Rohergebnisse als `str` beibehalten und das Parsen spezifischer Befehlsantworten direkt im `MqttCommandDispatcher` durchführen.
52+
* *Nachteil:* Vermischt die Zuständigkeiten. Der Dispatcher sollte nur das Routing und die Validierung übernehmen, während die `SignalduinoCommands` die Logik für die Kommunikation und das Parsen der Firmware-spezifischen Antworten enthalten sollten.
53+
* *Abgelehnt* wegen schlechter Architektur und Verstoß gegen das Single Responsibility Principle.
54+
55+
2. **Alternative 2: Globaler, einfacher String-Wrapper im Dispatcher:** Jede String-Antwort global in ein einfaches Dictionary wie `{'response': <String>}` verpacken.
56+
* *Nachteil:* Führt zu einer inkonsistenten API, da einige Befehle (wie `get/config/decoder`) semantisch reiche, parsbare Daten liefern, die als roher String versteckt wären.
57+
* *Abgelehnt* zugunsten einer semantisch korrekten, strukturierten Antwort.

signalduino/commands.py

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,46 @@ def __init__(self, send_command: Callable[..., Awaitable[Any]], mqtt_topic_root:
2424
self._send_command = send_command
2525
self.mqtt_topic_root = mqtt_topic_root
2626

27+
def _parse_decoder_config(self, response: str) -> Dict[str, int]:
28+
"""Parses the 'MS=1;MU=1;MC=1;Mred=1' firmware response into a dictionary of integers."""
29+
config: Dict[str, int] = {}
30+
for item in response.strip().split(';'):
31+
if '=' in item:
32+
key, val = item.split('=', 1)
33+
try:
34+
# Wir gehen davon aus, dass die Werte boolesch (0 oder 1) sind.
35+
config[key.strip()] = int(val.strip())
36+
except ValueError:
37+
logger.warning("Could not parse decoder config value '%s' for key '%s' as integer.", val, key)
38+
# Falls Parsing fehlschlägt, ignorieren wir den Wert, um Typkonsistenz zu wahren.
39+
pass
40+
return config
41+
2742
async def get_version(self, timeout: float = 2.0) -> str:
2843
"""Firmware version (V)"""
2944
return await self._send_command(command="V", expect_response=True, timeout=timeout)
3045

31-
async def get_free_ram(self, timeout: float = 2.0) -> str:
46+
async def get_free_ram(self, timeout: float = 2.0) -> int:
3247
"""Free RAM (R)"""
33-
return await self._send_command(command="R", expect_response=True, timeout=timeout)
48+
# Firmware typically responds with a numeric value (e.g., "1234")
49+
response_pattern = re.compile(r'^(\d+)$')
50+
response = await self._send_command(command="R", expect_response=True, timeout=timeout, response_pattern=response_pattern)
3451

35-
async def get_uptime(self, timeout: float = 2.0) -> str:
52+
match = response_pattern.match(response.strip())
53+
if match:
54+
return int(match.group(1))
55+
raise ValueError(f"Unexpected response format for Free RAM: {response}")
56+
57+
async def get_uptime(self, timeout: float = 2.0) -> int:
3658
"""System uptime (t)"""
37-
return await self._send_command(command="t", expect_response=True, timeout=timeout)
59+
# Firmware typically responds with a numeric value (e.g., "1234")
60+
response_pattern = re.compile(r'^(\d+)$')
61+
response = await self._send_command(command="t", expect_response=True, timeout=timeout, response_pattern=response_pattern)
62+
63+
match = response_pattern.match(response.strip())
64+
if match:
65+
return int(match.group(1))
66+
raise ValueError(f"Unexpected response format for Uptime: {response}")
3867

3968
async def get_cmds(self, timeout: float = 2.0) -> str:
4069
"""Available commands (?)"""
@@ -44,15 +73,18 @@ async def ping(self, timeout: float = 2.0) -> str:
4473
"""Ping (P)"""
4574
return await self._send_command(command="P", expect_response=True, timeout=timeout)
4675

47-
async def get_config(self, timeout: float = 2.0) -> str:
48-
"""Decoder configuration (CG)"""
49-
return await self._send_command(command="CG", expect_response=True, timeout=timeout)
76+
async def get_config(self, timeout: float = 2.0) -> Dict[str, int]:
77+
"""Decoder configuration (CG) - Returns parsed dictionary."""
78+
response = await self._send_command(command="CG", expect_response=True, timeout=timeout)
79+
return self._parse_decoder_config(response)
5080

51-
async def get_ccconf(self, timeout: float = 2.0) -> str:
52-
"""CC1101 configuration registers (C0DnF)"""
81+
async def get_ccconf(self, timeout: float = 2.0) -> Dict[str, str]:
82+
"""CC1101 configuration registers (C0DnF). Returns a dictionary with the raw string."""
5383
# Response-Pattern aus 00_SIGNALduino.pm, Zeile 86, angepasst an Python regex
54-
return await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11=[A-F0-9a-f]+'))
55-
84+
response = await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11=[A-F0-9a-f]+'))
85+
# Kapselt den rohen String, um die MQTT-Antwort konsistent als Dict zurückzugeben
86+
return {"cc1101_config_string": response}
87+
5688
async def get_ccpatable(self, timeout: float = 2.0) -> str:
5789
"""CC1101 PA table (C3E)"""
5890
# Response-Pattern aus 00_SIGNALduino.pm, Zeile 88
@@ -501,12 +533,12 @@ def create_value_schema(value_schema: Dict[str, Any]) -> Dict[str, Any]:
501533
COMMAND_MAP: Dict[str, Dict[str, Any]] = {
502534
# Phase 1: Einfache GET-Befehle (Core)
503535
'get/system/version': { 'method': 'get_version', 'schema': BASE_SCHEMA, 'description': 'Firmware version (V)' },
504-
'get/system/freeram': { 'method': 'get_freeram', 'schema': BASE_SCHEMA, 'description': 'Free RAM (R)' },
536+
'get/system/freeram': { 'method': 'get_free_ram', 'schema': BASE_SCHEMA, 'description': 'Free RAM (R)' },
505537
'get/system/uptime': { 'method': 'get_uptime', 'schema': BASE_SCHEMA, 'description': 'System uptime (t)' },
506-
'get/config/decoder': { 'method': 'get_config_decoder', 'schema': BASE_SCHEMA, 'description': 'Decoder configuration (CG)' },
507-
'get/cc1101/config': { 'method': 'get_cc1101_config', 'schema': BASE_SCHEMA, 'description': 'CC1101 configuration registers (C0DnF)' },
508-
'get/cc1101/patable': { 'method': 'get_cc1101_patable', 'schema': BASE_SCHEMA, 'description': 'CC1101 PA table (C3E)' },
509-
'get/cc1101/register': { 'method': 'get_cc1101_register', 'schema': BASE_SCHEMA, 'description': 'Read CC1101 register (C<reg>)' },
538+
'get/config/decoder': { 'method': 'get_config', 'schema': BASE_SCHEMA, 'description': 'Decoder configuration (CG)' },
539+
'get/cc1101/config': { 'method': 'get_ccconf', 'schema': BASE_SCHEMA, 'description': 'CC1101 configuration registers (C0DnF)' },
540+
'get/cc1101/patable': { 'method': 'get_ccpatable', 'schema': BASE_SCHEMA, 'description': 'CC1101 PA table (C3E)' },
541+
'get/cc1101/register': { 'method': 'read_cc1101_register', 'schema': BASE_SCHEMA, 'description': 'Read CC1101 register (C<reg>)' },
510542
'get/cc1101/frequency': { 'method': 'get_frequency', 'schema': BASE_SCHEMA, 'description': 'CC1101 current RF frequency' },
511543
'get/cc1101/settings': { 'method': 'get_cc1101_settings', 'schema': BASE_SCHEMA, 'description': 'CC1101 key configuration settings (freq, bw, rampl, sens, dr)' },
512544

signalduino/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
SDUINO_INIT_WAIT_XQ = 1.5
55
SDUINO_INIT_WAIT = 2.0
66
SDUINO_INIT_MAXRETRY = 3
7-
SDUINO_CMD_TIMEOUT = 10.0
7+
SDUINO_CMD_TIMEOUT = 15.0
88
SDUINO_KEEPALIVE_TIMEOUT = 60
99
SDUINO_KEEPALIVE_MAXRETRY = 3
1010
SDUINO_WRITEQUEUE_NEXT = 0.3

signalduino/controller.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,32 @@ async def get_version(self, payload: Dict[str, Any]) -> str:
8989
# commands.get_version ist eine asynchrone Methode in SignalduinoCommands, die 'V' sendet.
9090
return await self.commands.get_version()
9191

92+
async def get_free_ram(self, payload: Dict[str, Any]) -> int:
93+
"""Delegates to SignalduinoCommands to get the free RAM (R)."""
94+
return await self.commands.get_free_ram()
95+
96+
async def get_uptime(self, payload: Dict[str, Any]) -> int:
97+
"""Delegates to SignalduinoCommands to get the system uptime (t)."""
98+
return await self.commands.get_uptime()
99+
100+
async def get_config(self, payload: Dict[str, Any]) -> Dict[str, int]:
101+
"""Delegates to SignalduinoCommands to get the decoder configuration (CG)."""
102+
return await self.commands.get_config()
103+
104+
async def get_ccconf(self, payload: Dict[str, Any]) -> Dict[str, str]:
105+
"""Delegates to SignalduinoCommands to get the CC1101 config registers (C0DnF)."""
106+
return await self.commands.get_ccconf()
107+
108+
async def get_ccpatable(self, payload: Dict[str, Any]) -> str:
109+
"""Delegates to SignalduinoCommands to get the CC1101 PA table (C3E)."""
110+
return await self.commands.get_ccpatable()
111+
92112
async def get_frequency(self, payload: Dict[str, Any]) -> Dict[str, Any]:
93113
"""Delegates to SignalduinoCommands to get the current CC1101 frequency."""
94114
# Der Payload wird vom MqttCommandDispatcher übergeben, aber von commands.get_frequency ignoriert.
95115
return await self.commands.get_frequency(payload)
96116

97-
async def factory_reset(self, payload: Dict[str, Any]) -> str:
117+
async def factory_reset(self, payload: Dict[str, Any]) -> Dict[str, str]:
98118
"""Delegates to SignalduinoCommands to execute a factory reset (e)."""
99119
# Payload wird zur Validierung akzeptiert, aber ignoriert.
100120
return await self.commands.factory_reset()

0 commit comments

Comments
 (0)