From 19429511aa2878f6a6e5553967427d1a84fddeb6 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Tue, 6 Jan 2026 18:29:34 +0000 Subject: [PATCH 1/7] feat: fix mqtt timeouts by improving response parsing for cc1101 and config commands (ADR-004) --- docs/01_user_guide/mqtt_api.adoc | 57 +++++- .../ADR-004-mqtt-response-parsing.adoc | 57 ++++++ signalduino/commands.py | 64 +++++-- signalduino/constants.py | 2 +- signalduino/controller.py | 22 ++- tests/test_mqtt_commands.py | 149 ++++++++++++--- tools/sd_mqtt_cli.py | 178 ++++++++++++++++-- 7 files changed, 452 insertions(+), 77 deletions(-) create mode 100644 docs/architecture/decisions/ADR-004-mqtt-response-parsing.adoc diff --git a/docs/01_user_guide/mqtt_api.adoc b/docs/01_user_guide/mqtt_api.adoc index 4fc18c6..81e6db0 100644 --- a/docs/01_user_guide/mqtt_api.adoc +++ b/docs/01_user_guide/mqtt_api.adoc @@ -65,6 +65,47 @@ Eine erfolgreiche Response auf `signalduino/v1/responses` hat folgende Struktur: } ---- +[[_cli_tool]] +== CLI Tool zur Steuerung (`tools/sd_mqtt_cli.py`) + +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. + +=== Installation und Ausführung + +Das Tool benötigt die `paho-mqtt` Abhängigkeit, die in der `requirements-dev.txt` enthalten ist. + +[source,bash] +---- +pip install paho-mqtt +python3 tools/sd_mqtt_cli.py --help +---- + +=== Verfügbare Kommandos + +|=== +| Kommando | Beschreibung | Beispiel + +| `reset` +| Führt einen Factory Reset durch (`set/factory_reset`). +| `python3 tools/sd_mqtt_cli.py reset` + +| `get all-settings` +| Fragt alle wichtigen CC1101-Einstellungen in einer aggregierten Nachricht ab. +| `python3 tools/sd_mqtt_cli.py get all-settings` + +| `get hardware-status --parameter ` +| Fragt einen spezifischen CC1101-Parameter ab. Parameter: `frequency`, `bandwidth`, `rampl`, `sensitivity`, `datarate`. +| `python3 tools/sd_mqtt_cli.py get hardware-status --parameter frequency` + +| `get system-status --parameter ` +| **NEU:** Fragt einen spezifischen System-Parameter ab. Parameter: `version`, `freeram`, `uptime`. +| `python3 tools/sd_mqtt_cli.py get system-status --parameter freeram` + +| `poll` +| **NEU:** Fragt nacheinander alle verfügbaren System- und CC1101-Parameter ab. Nützlich zur Diagnose des aktuellen Gerätezustands. +| `python3 tools/sd_mqtt_cli.py poll` +|=== + [[_get_commands]] == GET Commands (Status und Konfiguration abrufen) @@ -79,20 +120,20 @@ GET-Befehle benötigen eine leere Payload (`{}`) oder nur eine `req_id`. | Firmware-Version. | `get/system/freeram` -| `"1234"` -| Verfügbarer RAM-Speicher. +| `1234` +| Verfügbarer RAM-Speicher (`int`). | `get/system/uptime` -| `"56789"` -| System-Laufzeit. +| `56789` +| System-Laufzeit (`int`). | `get/config/decoder` -| `"MS=1;MU=1;MC=1;MN=1"` -| Aktuelle Decoder-Konfiguration (aktivierte Protokollfamilien). +| `{"MS": 1, "MU": 1, "MC": 1, "MN": 1}` +| Aktuelle Decoder-Konfiguration (aktivierte Protokollfamilien) als geparstes Dictionary. | `get/cc1101/config` -| `"C0D11=0F"` -| CC1101 Konfigurationsregister-Dump. +| `{"cc1101_config_string": "C0D11=0F"}` +| CC1101 Konfigurationsregister-Dump als gekapselter String. | `get/cc1101/patable` | `"C3E = C0 C1 C2 C3 C4 C5 C6 C7"` diff --git a/docs/architecture/decisions/ADR-004-mqtt-response-parsing.adoc b/docs/architecture/decisions/ADR-004-mqtt-response-parsing.adoc new file mode 100644 index 0000000..0ab57f0 --- /dev/null +++ b/docs/architecture/decisions/ADR-004-mqtt-response-parsing.adoc @@ -0,0 +1,57 @@ += ADR-004: Strukturiertes Parsing serieller Antworten für MQTT GET-Befehle +:revdate: 2026-01-06 +:author: Roo + +== 1. Kontext + +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. + +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. + +Betroffene Befehle und ihre Rohantwortformate: +* `get/config/decoder` (CG): `MS=1;MU=1;MC=1;Mred=1\n` +* `get/cc1101/config` (C0DnF): `C0Dn11=\n` + +Zusätzlich müssen alle `get` Befehle, die einen rohen String zurückgeben, angepasst werden, um die Konsistenz des MQTT-API zu gewährleisten. + +== 2. Entscheidung + +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. + +Dies stellt sicher, dass alle erfolgreichen `GET` Anfragen über MQTT eine strukturierte und maschinenlesbare JSON-Antwort erhalten und die Timeouts vermieden werden. + +=== Detaillierte Logik-Anpassungen + +1. **`get_config` (CG):** + * Wird eine private Hilfsfunktion `_parse_decoder_config(response: str) -> Dict[str, int]` in [`signalduino/commands.py`](signalduino/commands.py) implementiert. + * Diese Funktion parst den `key=value;` String in ein Dictionary (z.B. `{'MS': 1, 'MU': 1, 'MC': 1, 'Mred': 1}`). + * Der Rückgabetyp von `get_config` wird von `str` auf `Dict[str, int]` geändert. + +2. **`get_ccconf` (C0DnF):** + * Diese Methode gibt einen String wie `C0Dn11=` zurück. + * Die Methode wird angepasst, um die rohe String-Antwort in ein Dictionary zu kapseln, z.B. `{'cc1101_config_string': response_string}`. + * Der Rückgabetyp von `get_ccconf` wird von `str` auf `Dict[str, str]` geändert. + +3. **Weitere einfache GET-Befehle:** + * 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. + +== 3. Konsequenzen + +=== Positive +* **Behebung der Timeouts:** Die MQTT GET-Befehle für Konfigurationen werden korrekt beantwortet und die Timeouts behoben. +* **API-Konsistenz:** Alle MQTT `GET` Antworten liefern nun eine konsistente, JSON-serialisierbare Struktur. +* **Wartbarkeit:** Der Code wird robuster, da das Parsing der seriellen Antwort in der `commands.py`-Schicht zentralisiert ist. + +=== Negative +* **Refactoring:** Es müssen kleinere Refactorings in [`signalduino/commands.py`](signalduino/commands.py) durchgeführt werden, um die Rückgabetypen der Methoden anzupassen. +* **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. + +== 4. Alternativen + +1. **Alternative 1: Parsing im `MqttCommandDispatcher`:** Die Rohergebnisse als `str` beibehalten und das Parsen spezifischer Befehlsantworten direkt im `MqttCommandDispatcher` durchführen. + * *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. + * *Abgelehnt* wegen schlechter Architektur und Verstoß gegen das Single Responsibility Principle. + +2. **Alternative 2: Globaler, einfacher String-Wrapper im Dispatcher:** Jede String-Antwort global in ein einfaches Dictionary wie `{'response': }` verpacken. + * *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. + * *Abgelehnt* zugunsten einer semantisch korrekten, strukturierten Antwort. diff --git a/signalduino/commands.py b/signalduino/commands.py index 243f298..88bffab 100644 --- a/signalduino/commands.py +++ b/signalduino/commands.py @@ -24,17 +24,46 @@ def __init__(self, send_command: Callable[..., Awaitable[Any]], mqtt_topic_root: self._send_command = send_command self.mqtt_topic_root = mqtt_topic_root + def _parse_decoder_config(self, response: str) -> Dict[str, int]: + """Parses the 'MS=1;MU=1;MC=1;Mred=1' firmware response into a dictionary of integers.""" + config: Dict[str, int] = {} + for item in response.strip().split(';'): + if '=' in item: + key, val = item.split('=', 1) + try: + # Wir gehen davon aus, dass die Werte boolesch (0 oder 1) sind. + config[key.strip()] = int(val.strip()) + except ValueError: + logger.warning("Could not parse decoder config value '%s' for key '%s' as integer.", val, key) + # Falls Parsing fehlschlägt, ignorieren wir den Wert, um Typkonsistenz zu wahren. + pass + return config + async def get_version(self, timeout: float = 2.0) -> str: """Firmware version (V)""" return await self._send_command(command="V", expect_response=True, timeout=timeout) - async def get_free_ram(self, timeout: float = 2.0) -> str: + async def get_free_ram(self, timeout: float = 2.0) -> int: """Free RAM (R)""" - return await self._send_command(command="R", expect_response=True, timeout=timeout) + # Firmware typically responds with a numeric value (e.g., "1234") + response_pattern = re.compile(r'^(\d+)$') + response = await self._send_command(command="R", expect_response=True, timeout=timeout, response_pattern=response_pattern) - async def get_uptime(self, timeout: float = 2.0) -> str: + match = response_pattern.match(response.strip()) + if match: + return int(match.group(1)) + raise ValueError(f"Unexpected response format for Free RAM: {response}") + + async def get_uptime(self, timeout: float = 2.0) -> int: """System uptime (t)""" - return await self._send_command(command="t", expect_response=True, timeout=timeout) + # Firmware typically responds with a numeric value (e.g., "1234") + response_pattern = re.compile(r'^(\d+)$') + response = await self._send_command(command="t", expect_response=True, timeout=timeout, response_pattern=response_pattern) + + match = response_pattern.match(response.strip()) + if match: + return int(match.group(1)) + raise ValueError(f"Unexpected response format for Uptime: {response}") async def get_cmds(self, timeout: float = 2.0) -> str: """Available commands (?)""" @@ -44,15 +73,18 @@ async def ping(self, timeout: float = 2.0) -> str: """Ping (P)""" return await self._send_command(command="P", expect_response=True, timeout=timeout) - async def get_config(self, timeout: float = 2.0) -> str: - """Decoder configuration (CG)""" - return await self._send_command(command="CG", expect_response=True, timeout=timeout) + async def get_config(self, timeout: float = 2.0) -> Dict[str, int]: + """Decoder configuration (CG) - Returns parsed dictionary.""" + response = await self._send_command(command="CG", expect_response=True, timeout=timeout) + return self._parse_decoder_config(response) - async def get_ccconf(self, timeout: float = 2.0) -> str: - """CC1101 configuration registers (C0DnF)""" + async def get_ccconf(self, timeout: float = 2.0) -> Dict[str, str]: + """CC1101 configuration registers (C0DnF). Returns a dictionary with the raw string.""" # Response-Pattern aus 00_SIGNALduino.pm, Zeile 86, angepasst an Python regex - return await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11=[A-F0-9a-f]+')) - + response = await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11=[A-F0-9a-f]+')) + # Kapselt den rohen String, um die MQTT-Antwort konsistent als Dict zurückzugeben + return {"cc1101_config_string": response} + async def get_ccpatable(self, timeout: float = 2.0) -> str: """CC1101 PA table (C3E)""" # Response-Pattern aus 00_SIGNALduino.pm, Zeile 88 @@ -501,12 +533,12 @@ def create_value_schema(value_schema: Dict[str, Any]) -> Dict[str, Any]: COMMAND_MAP: Dict[str, Dict[str, Any]] = { # Phase 1: Einfache GET-Befehle (Core) 'get/system/version': { 'method': 'get_version', 'schema': BASE_SCHEMA, 'description': 'Firmware version (V)' }, - 'get/system/freeram': { 'method': 'get_freeram', 'schema': BASE_SCHEMA, 'description': 'Free RAM (R)' }, + 'get/system/freeram': { 'method': 'get_free_ram', 'schema': BASE_SCHEMA, 'description': 'Free RAM (R)' }, 'get/system/uptime': { 'method': 'get_uptime', 'schema': BASE_SCHEMA, 'description': 'System uptime (t)' }, - 'get/config/decoder': { 'method': 'get_config_decoder', 'schema': BASE_SCHEMA, 'description': 'Decoder configuration (CG)' }, - 'get/cc1101/config': { 'method': 'get_cc1101_config', 'schema': BASE_SCHEMA, 'description': 'CC1101 configuration registers (C0DnF)' }, - 'get/cc1101/patable': { 'method': 'get_cc1101_patable', 'schema': BASE_SCHEMA, 'description': 'CC1101 PA table (C3E)' }, - 'get/cc1101/register': { 'method': 'get_cc1101_register', 'schema': BASE_SCHEMA, 'description': 'Read CC1101 register (C)' }, + 'get/config/decoder': { 'method': 'get_config', 'schema': BASE_SCHEMA, 'description': 'Decoder configuration (CG)' }, + 'get/cc1101/config': { 'method': 'get_ccconf', 'schema': BASE_SCHEMA, 'description': 'CC1101 configuration registers (C0DnF)' }, + 'get/cc1101/patable': { 'method': 'get_ccpatable', 'schema': BASE_SCHEMA, 'description': 'CC1101 PA table (C3E)' }, + 'get/cc1101/register': { 'method': 'read_cc1101_register', 'schema': BASE_SCHEMA, 'description': 'Read CC1101 register (C)' }, 'get/cc1101/frequency': { 'method': 'get_frequency', 'schema': BASE_SCHEMA, 'description': 'CC1101 current RF frequency' }, 'get/cc1101/settings': { 'method': 'get_cc1101_settings', 'schema': BASE_SCHEMA, 'description': 'CC1101 key configuration settings (freq, bw, rampl, sens, dr)' }, diff --git a/signalduino/constants.py b/signalduino/constants.py index 7a447bb..d038501 100644 --- a/signalduino/constants.py +++ b/signalduino/constants.py @@ -4,7 +4,7 @@ SDUINO_INIT_WAIT_XQ = 1.5 SDUINO_INIT_WAIT = 2.0 SDUINO_INIT_MAXRETRY = 3 -SDUINO_CMD_TIMEOUT = 10.0 +SDUINO_CMD_TIMEOUT = 15.0 SDUINO_KEEPALIVE_TIMEOUT = 60 SDUINO_KEEPALIVE_MAXRETRY = 3 SDUINO_WRITEQUEUE_NEXT = 0.3 diff --git a/signalduino/controller.py b/signalduino/controller.py index 97231a2..98f7fb1 100644 --- a/signalduino/controller.py +++ b/signalduino/controller.py @@ -89,12 +89,32 @@ async def get_version(self, payload: Dict[str, Any]) -> str: # commands.get_version ist eine asynchrone Methode in SignalduinoCommands, die 'V' sendet. return await self.commands.get_version() + async def get_free_ram(self, payload: Dict[str, Any]) -> int: + """Delegates to SignalduinoCommands to get the free RAM (R).""" + return await self.commands.get_free_ram() + + async def get_uptime(self, payload: Dict[str, Any]) -> int: + """Delegates to SignalduinoCommands to get the system uptime (t).""" + return await self.commands.get_uptime() + + async def get_config(self, payload: Dict[str, Any]) -> Dict[str, int]: + """Delegates to SignalduinoCommands to get the decoder configuration (CG).""" + return await self.commands.get_config() + + async def get_ccconf(self, payload: Dict[str, Any]) -> Dict[str, str]: + """Delegates to SignalduinoCommands to get the CC1101 config registers (C0DnF).""" + return await self.commands.get_ccconf() + + async def get_ccpatable(self, payload: Dict[str, Any]) -> str: + """Delegates to SignalduinoCommands to get the CC1101 PA table (C3E).""" + return await self.commands.get_ccpatable() + async def get_frequency(self, payload: Dict[str, Any]) -> Dict[str, Any]: """Delegates to SignalduinoCommands to get the current CC1101 frequency.""" # Der Payload wird vom MqttCommandDispatcher übergeben, aber von commands.get_frequency ignoriert. return await self.commands.get_frequency(payload) - async def factory_reset(self, payload: Dict[str, Any]) -> str: + async def factory_reset(self, payload: Dict[str, Any]) -> Dict[str, str]: """Delegates to SignalduinoCommands to execute a factory reset (e).""" # Payload wird zur Validierung akzeptiert, aber ignoriert. return await self.commands.factory_reset() diff --git a/tests/test_mqtt_commands.py b/tests/test_mqtt_commands.py index 3fdaffb..08feedf 100644 --- a/tests/test_mqtt_commands.py +++ b/tests/test_mqtt_commands.py @@ -122,26 +122,74 @@ async def test_controller_handles_version_command(signalduino_controller, mock_a @pytest.mark.asyncio async def test_controller_handles_freeram_command(signalduino_controller, mock_aiomqtt_client_cls): - """Test handling of the 'freeram' command.""" + """ + Test handling of the 'freeram' command, expecting an integer value. + This also verifies the correct response_pattern is passed to _send_command. + """ + + # 1. Mock _send_command, das die Raw-Antwort (die das Regex matchen soll) liefert + raw_response_line = "1234\n" + send_command_mock = AsyncMock(return_value=raw_response_line.strip()) + signalduino_controller.commands._send_command = send_command_mock + + # 2. Dispatcher und Payload vorbereiten + command_path = "get/system/freeram" + mqtt_payload = '{"req_id": "test_freeram"}' + + dispatcher = MqttCommandDispatcher(controller=signalduino_controller) + async with signalduino_controller: - await run_mqtt_command_test( - signalduino_controller, - mock_aiomqtt_client_cls, - mqtt_cmd="freeram", - raw_cmd="R", - expected_response_line="1234\n" + result = await dispatcher.dispatch(command_path, mqtt_payload) + + # 3. Assertions + assert result['status'] == "OK" + assert result['req_id'] == "test_freeram" + # Erwartet den geparsten Integer-Wert + assert result['data'] == 1234 + + # 4. Überprüfe, ob send_command mit dem korrekten Befehl und dem Regex aufgerufen wurde + expected_pattern = re.compile(r'^(\d+)$') + send_command_mock.assert_called_once_with( + command='R', + expect_response=True, + timeout=2.0, + response_pattern=expected_pattern ) + @pytest.mark.asyncio async def test_controller_handles_uptime_command(signalduino_controller, mock_aiomqtt_client_cls): - """Test handling of the 'uptime' command.""" + """ + Test handling of the 'uptime' command, expecting an integer value. + """ + + # 1. Mock _send_command + raw_response_line = "56789\n" + send_command_mock = AsyncMock(return_value=raw_response_line.strip()) + signalduino_controller.commands._send_command = send_command_mock + + # 2. Dispatcher und Payload vorbereiten + command_path = "get/system/uptime" + mqtt_payload = '{"req_id": "test_uptime"}' + + dispatcher = MqttCommandDispatcher(controller=signalduino_controller) + async with signalduino_controller: - await run_mqtt_command_test( - signalduino_controller, - mock_aiomqtt_client_cls, - mqtt_cmd="uptime", - raw_cmd="t", - expected_response_line="56789\n" + result = await dispatcher.dispatch(command_path, mqtt_payload) + + # 3. Assertions + assert result['status'] == "OK" + assert result['req_id'] == "test_uptime" + # Erwartet den geparsten Integer-Wert + assert result['data'] == 56789 + + # 4. Überprüfe, ob send_command mit dem korrekten Befehl und dem Regex aufgerufen wurde + expected_pattern = re.compile(r'^(\d+)$') + send_command_mock.assert_called_once_with( + command='t', + expect_response=True, + timeout=2.0, + response_pattern=expected_pattern ) @pytest.mark.asyncio @@ -170,29 +218,68 @@ async def test_controller_handles_ping_command(signalduino_controller, mock_aiom @pytest.mark.asyncio async def test_controller_handles_config_command(signalduino_controller, mock_aiomqtt_client_cls): - """Test handling of the 'config' command.""" + """ + Test handling of the 'config' command, expecting a parsed decoder configuration dictionary. + """ + # 1. Mock _send_command + raw_response_line = "MS=1;MU=1;MC=1;MN=1\n" + send_command_mock = AsyncMock(return_value=raw_response_line.strip()) + signalduino_controller.commands._send_command = send_command_mock + + # 2. Dispatcher und Payload vorbereiten + command_path = "get/config/decoder" + mqtt_payload = '{"req_id": "test_config"}' + + dispatcher = MqttCommandDispatcher(controller=signalduino_controller) + async with signalduino_controller: - await run_mqtt_command_test( - signalduino_controller, - mock_aiomqtt_client_cls, - mqtt_cmd="config", - raw_cmd="CG", - expected_response_line="MS=1;MU=1;MC=1;MN=1\n" + result = await dispatcher.dispatch(command_path, mqtt_payload) + + # 3. Assertions + assert result['status'] == "OK" + assert result['req_id'] == "test_config" + # Erwartet das geparste Dictionary + assert result['data'] == {'MS': 1, 'MU': 1, 'MC': 1, 'MN': 1} + + # 4. Überprüfe, ob send_command mit dem korrekten Befehl aufgerufen wurde + send_command_mock.assert_called_once_with( + command='CG', + expect_response=True, + timeout=2.0 ) @pytest.mark.asyncio async def test_controller_handles_ccconf_command(signalduino_controller, mock_aiomqtt_client_cls): - """Test handling of the 'ccconf' command.""" - # The regex r"C0Dn11=[A-F0-9a-f]+" is quite specific. The response is multi-line in reality, - # but the controller only matches the first line that matches the pattern. - # We simulate the first matching line. + """ + Test handling of the 'ccconf' command, expecting the raw string wrapped in a dictionary. + """ + # 1. Mock _send_command + raw_response_line = "C0D11=0F" # Die Rohantwort vom Controller ist ohne \n + send_command_mock = AsyncMock(return_value=raw_response_line) + signalduino_controller.commands._send_command = send_command_mock + + # 2. Dispatcher und Payload vorbereiten + command_path = "get/cc1101/config" + mqtt_payload = '{"req_id": "test_ccconf"}' + + dispatcher = MqttCommandDispatcher(controller=signalduino_controller) + async with signalduino_controller: - await run_mqtt_command_test( - controller=signalduino_controller, - mock_aiomqtt_client_cls=mock_aiomqtt_client_cls, - mqtt_cmd="ccconf", - raw_cmd="C0DnF", - expected_response_line="C0D11=0F\n" + result = await dispatcher.dispatch(command_path, mqtt_payload) + + # 3. Assertions + assert result['status'] == "OK" + assert result['req_id'] == "test_ccconf" + # Erwartet den gekapselten String + assert result['data'] == {'cc1101_config_string': 'C0D11=0F'} + + # 4. Überprüfe, ob send_command mit dem korrekten Befehl und Pattern aufgerufen wurde + expected_pattern = re.compile(r'C0Dn11=[A-F0-9a-f]+') + send_command_mock.assert_called_once_with( + command='C0DnF', + expect_response=True, + timeout=2.0, + response_pattern=expected_pattern ) @pytest.mark.asyncio diff --git a/tools/sd_mqtt_cli.py b/tools/sd_mqtt_cli.py index 654ab43..5bcada3 100644 --- a/tools/sd_mqtt_cli.py +++ b/tools/sd_mqtt_cli.py @@ -6,6 +6,7 @@ from paho.mqtt.enums import CallbackAPIVersion from dotenv import load_dotenv import time +import uuid # Konfiguration BASE_TOPIC = "signalduino/v1" @@ -13,6 +14,21 @@ RESP_TOPIC = f"{BASE_TOPIC}/responses" ERR_TOPIC = f"{BASE_TOPIC}/errors" +# Liste der abzufragenden Topics für den Polling-Modus +POLL_TOPICS = [ + 'get/system/version', + 'get/system/freeram', + 'get/system/uptime', + 'get/cc1101/frequency', + 'get/cc1101/bandwidth', + 'get/cc1101/datarate', + 'get/cc1101/rampl', + 'get/cc1101/sensitivity', + 'get/cc1101/patable', + 'get/cc1101/config', + 'get/config/decoder', +] + class MqttCli: """A simple CLI tool to send commands to the PySignalduino MQTT gateway.""" @@ -22,6 +38,8 @@ def __init__(self, host: str, port: int, req_id: str, timeout: int = 5): self.req_id = req_id self.timeout = timeout self.response = None + self.is_connected = False # NEU + self.is_subscribed = False # NEU # Verwenden Sie paho.mqtt.client, um die Antwort zu abonnieren. self.client = Client(callback_api_version=CallbackAPIVersion.VERSION2, client_id=f"sd-mqtt-cli-{req_id}") self.client.on_connect = self.on_connect @@ -29,9 +47,26 @@ def __init__(self, host: str, port: int, req_id: str, timeout: int = 5): def on_connect(self, client, userdata, flags, reason_code, properties): if reason_code == 0: - client.subscribe(RESP_TOPIC) - client.subscribe(ERR_TOPIC) - print("Info: Subscribed to response topics.") + self.is_connected = True # Setze Flag + # client.subscribe() ist blockierend, aber wir müssen auf den QOS-Rückruf warten, + # was paho.mqtt.client im Hintergrund übernimmt. Wir verlassen uns darauf, dass + # subscribe jetzt aufgerufen wird, aber müssen nicht explizit auf QOS warten, + # da die `on_connect` synchron läuft. + # Ich ersetze client.subscribe(RESP_TOPIC) mit client.subscribe(RESP_TOPIC, qos=1) + # und setze self.is_subscribed danach auf True. + + # Subskription durchführen + # client.subscribe gibt ein Tuple zurück: (return_code, mid) + result, mid = client.subscribe(RESP_TOPIC, qos=1) + if result == 0: + self.is_subscribed = True # Setze Flag, da der Aufruf erfolgreich war + print("Info: Subscribed to response topics.") + else: + print(f"Error: Failed to subscribe with result code: {result}") + + # Separate Subscription für Errors + client.subscribe(ERR_TOPIC, qos=0) + else: print(f"Error: Connection failed with reason: {reason_code}") @@ -41,33 +76,57 @@ def on_message(self, client, userdata, msg): # Überprüfe auf die korrekte req_id if payload.get("req_id") == self.req_id: self.response = payload - # Disconnect, um die Schleife zu beenden - self.client.loop_stop() + # Bei persistenten Verbindungen MUSS die Schleife weiterlaufen, + # um weitere Nachrichten zu empfangen. Wir stoppen den Loop NICHT hier. + pass except json.JSONDecodeError: pass # Ignoriere ungültiges JSON - def send_command(self, topic_suffix: str, payload_data: dict = {}) -> dict: + def connect_and_subscribe(self) -> dict: + """Stellt die Verbindung her, startet den Loop und abonniert die Response-Topics. + Gibt {"success": True} bei Erfolg oder ein Fehler-Dict bei Timeout/Fehler zurück. + """ try: self.client.connect(self.host, self.port, 60) except Exception as e: return {"success": False, "error": f"Failed to connect to MQTT broker: {e}"} + # Startet den Loop im Hintergrund, damit on_connect und on_message funktionieren self.client.loop_start() + start_time = time.time() + # Warte auf Verbindung und Abonnement. Max. 5 Sekunden für Flags. + while (not self.is_connected or not self.is_subscribed) and (time.time() - start_time) < 5.0: + time.sleep(0.05) + + if not self.is_connected or not self.is_subscribed: + return {"success": False, "error": "Timeout waiting for connection or subscription to be active."} + + return {"success": True} + + def disconnect_and_stop(self): + """Stoppt den Loop und trennt die Verbindung.""" + self.client.loop_stop() + self.client.disconnect() + print("Info: Disconnected from MQTT broker.") + + def execute_command(self, topic_suffix: str, payload_data: dict = {}) -> dict: + """Veröffentlicht einen Befehl und wartet auf die Antwort. + Verbindung/Trennung wird hier NICHT gehandhabt. + """ + self.response = None # Antwort für diesen Befehl zurücksetzen + full_topic = f"{CMD_TOPIC}/{topic_suffix}" payload_data["req_id"] = self.req_id payload = json.dumps(payload_data) - print(f"-> Sending command to {full_topic}: {payload}") + print(f"-> Sending command to {full_topic} (req_id: {self.req_id})") self.client.publish(full_topic, payload) start_time = time.time() # Warte, bis die Antwort empfangen wird oder Timeout erreicht ist while self.response is None and (time.time() - start_time) < self.timeout: time.sleep(0.1) - - self.client.loop_stop() - self.client.disconnect() if self.response: return self.response @@ -87,7 +146,6 @@ def run_cli(): parser = argparse.ArgumentParser(description="CLI for PySignalduino MQTT commands.") parser.add_argument("--host", default=default_host, help=f"MQTT broker host. Defaults to $MQTT_HOST or {default_host}.") parser.add_argument("--port", type=int, default=default_port, help=f"MQTT broker port. Defaults to $MQTT_PORT or {default_port}.") - parser.add_argument("--req-id", default=str(int(time.time())), help="Request ID for response correlation.") # Der Hauptparser muss zuerst die subparser hinzufügen subparsers = parser.add_subparsers(dest="command", required=True) @@ -95,8 +153,13 @@ def run_cli(): # 1. Factory Reset Command reset_parser = subparsers.add_parser("reset", help="Execute a Factory Reset (EEPROM Defaults).") - # 2. Get Hardware Status Commands (grouped) + # 2. Poll All Settings Command (NEU) + poll_parser = subparsers.add_parser("poll", help="Query all system and CC1101 settings sequentially.") + + # 3. Get Hardware Status Commands (grouped) get_parser = subparsers.add_parser("get", help="Retrieve hardware settings.") + # Füge req-id zum get-Subparser hinzu, da es nur hier benötigt wird + get_parser.add_argument("--req-id", default=str(int(time.time())), help="Request ID for response correlation.") get_subparsers = get_parser.add_subparsers(dest="setting", required=True) # NEU: Subcommand für alle CC1101-Einstellungen @@ -110,21 +173,96 @@ def run_cli(): required=True, help="The hardware parameter to query." ) + + # System Status Subcommand + sys_parser = get_subparsers.add_parser("system-status", help="Get system status (version, freeram, uptime).") + sys_parser.add_argument( + "--parameter", + choices=["version", "freeram", "uptime"], + required=True, + help="The system parameter to query." + ) args = parser.parse_args() - cli = MqttCli(host=args.host, port=args.port, req_id=args.req_id) result = None if args.command == "reset": - result = cli.send_command("set/factory_reset") + # Erstelle CLI Instanz mit einer eindeutigen req_id + req_id = str(uuid.uuid4()) + cli = MqttCli(host=args.host, port=args.port, req_id=req_id) + + connect_result = cli.connect_and_subscribe() + if connect_result.get("success") is True: + result = cli.execute_command("set/factory_reset") + cli.disconnect_and_stop() + else: + result = connect_result + + elif args.command == "poll": + print("\n--- Starting Sequential Poll ---") + all_results = {} + # Erstelle EINE CLI-Instanz für alle Befehle + req_id_base = str(uuid.uuid4()) # Nur zur Erstellung der Client-ID + cli = MqttCli(host=args.host, port=args.port, req_id=req_id_base, timeout=5) + + # 1. Verbindung herstellen und abonnieren + connect_result = cli.connect_and_subscribe() + if connect_result.get("success") is not True: + # Verbindung fehlgeschlagen (es wird ein Fehler-Dict zurückgegeben) + all_results["connection_error"] = connect_result + result = {"poll_summary": all_results} + # Der Loop wurde in connect_and_subscribe gestartet, muss aber gestoppt werden. + cli.disconnect_and_stop() + print("--- Poll Aborted ---") + + else: + # 2. Gehe die Liste der Topics durch und frage jeden Parameter ab + for topic_suffix in POLL_TOPICS: + # Für jeden Befehl EINE NEUE req_id generieren + req_id = str(uuid.uuid4()) + cli.req_id = req_id # WICHTIG: Die req_id muss für jeden Befehl neu gesetzt werden + + # Verwende einen Topic-Key, der für das Zusammenfassungs-Dictionary lesbar ist + topic_key = topic_suffix.replace('get/', '').replace('/', '_') + + # Führe Befehl auf persistenter Verbindung aus + response = cli.execute_command(topic_suffix) + all_results[topic_key] = response + + if response.get("success", False): + print(f"-> OK: {topic_suffix} -> {json.dumps(response.get('payload'))}") + else: + print(f"-> ERROR: {topic_suffix} -> {response.get('error', 'Timeout or connection failed.')}") + + # Warte kurz, um System nicht zu überlasten + time.sleep(1.5) + + # 3. Verbindung trennen + cli.disconnect_and_stop() + + print("\n--- Poll Complete ---") + print("Summary of all results:") + result = {"poll_summary": all_results} elif args.command == "get": - if args.setting == "all-settings": - result = cli.send_command("get/cc1101/settings", {}) - elif args.setting == "hardware-status": - topic_suffix = f"get/cc1101/{args.parameter}" - result = cli.send_command(topic_suffix, {}) + # Die req_id ist jetzt direkt an get_parser gebunden + cli = MqttCli(host=args.host, port=args.port, req_id=args.req_id) + + connect_result = cli.connect_and_subscribe() + if connect_result.get("success") is True: + if args.setting == "all-settings": + result = cli.execute_command("get/cc1101/settings", {}) + elif args.setting == "hardware-status": + topic_suffix = f"get/cc1101/{args.parameter}" + result = cli.execute_command(topic_suffix, {}) + elif args.setting == "system-status": + topic_suffix = f"get/system/{args.parameter}" + result = cli.execute_command(topic_suffix, {}) + + cli.disconnect_and_stop() + else: + result = connect_result if result: print(json.dumps(result, indent=2)) @@ -138,4 +276,4 @@ def run_cli(): print("pip install paho-mqtt") if __name__ == "__main__": - run_cli() + run_cli() \ No newline at end of file From fc07efe7ab8f0af3bf5f2acb0401c71877156f28 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Tue, 6 Jan 2026 18:32:33 +0000 Subject: [PATCH 2/7] fix: correct command response pattern access in controller and add pattern for CG command --- signalduino/commands.py | 8 +++++++- signalduino/controller.py | 19 +++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/signalduino/commands.py b/signalduino/commands.py index 88bffab..ea874d5 100644 --- a/signalduino/commands.py +++ b/signalduino/commands.py @@ -75,7 +75,13 @@ async def ping(self, timeout: float = 2.0) -> str: async def get_config(self, timeout: float = 2.0) -> Dict[str, int]: """Decoder configuration (CG) - Returns parsed dictionary.""" - response = await self._send_command(command="CG", expect_response=True, timeout=timeout) + config_pattern = re.compile(r'^MS=[01];MU=[01];MC=[01];Mred=[01](;M[A-Za-z0-9]+=[01])*$') + response = await self._send_command( + command="CG", + expect_response=True, + timeout=timeout, + response_pattern=config_pattern + ) return self._parse_decoder_config(response) async def get_ccconf(self, timeout: float = 2.0) -> Dict[str, str]: diff --git a/signalduino/controller.py b/signalduino/controller.py index 98f7fb1..76a93e6 100644 --- a/signalduino/controller.py +++ b/signalduino/controller.py @@ -360,17 +360,20 @@ async def _handle_as_command_response(self, line: str) -> None: self.logger.debug(f"Current pending responses: {len(self._pending_responses)}") for pending in self._pending_responses: try: - self.logger.debug(f"Checking pending response: {pending.payload}") - if pending.response_pattern: - self.logger.debug(f"Testing pattern: {pending.response_pattern}") - if pending.response_pattern.match(line): - self.logger.debug(f"Matched response pattern for command: {pending.payload}") + self.logger.debug(f"Checking pending response for command: {pending.command.payload}. Line: {line.strip()}") + + pattern = pending.command.response_pattern + if pattern: + self.logger.debug(f"Testing pattern: {pattern.pattern}") + if pattern.match(line): + self.logger.debug(f"Matched response pattern for command: {pending.command.payload}") pending.future.set_result(line) self._pending_responses.remove(pending) return - self.logger.debug(f"Testing direct match for: {pending.payload}") - if line.startswith(pending.payload): - self.logger.debug(f"Matched direct response for command: {pending.payload}") + + self.logger.debug(f"Testing direct match for: {pending.command.payload}") + if line.startswith(pending.command.payload): + self.logger.debug(f"Matched direct response for command: {pending.command.payload}") pending.future.set_result(line) self._pending_responses.remove(pending) return From d15b460de4e88dc77a64b8a7440ed26a9305bf59 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Tue, 6 Jan 2026 18:39:54 +0000 Subject: [PATCH 3/7] fix: adjust response pattern for individual CC1101 register GET commands --- signalduino/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signalduino/commands.py b/signalduino/commands.py index ea874d5..77d0a86 100644 --- a/signalduino/commands.py +++ b/signalduino/commands.py @@ -264,7 +264,7 @@ async def read_cc1101_register(self, register_address: int, timeout: float = 2.0 """Read CC1101 register (C)""" hex_addr = f"{register_address:02X}" # Response-Pattern: ccreg 00: oder Cxx = yy (aus 00_SIGNALduino.pm, Zeile 87) - return await self._send_command(command=f"C{hex_addr}", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C[A-Fa-f0-9]{2}\s=\s[0-9A-Fa-f]+$|ccreg 00:')) + return await self._send_command(command=f"C{hex_addr}", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C[A-Fa-f0-9]{2}\s=\s[0-9A-Fa-f]+|ccreg 00:')) async def _get_frequency_registers(self) -> int: """Liest die CC1101 Frequenzregister (FREQ2, FREQ1, FREQ0) und kombiniert sie zu einem 24-Bit-Wert (F_REG).""" From ca0f7bfc0358cc96faa3be5a45b01b524ab45a83 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Tue, 6 Jan 2026 18:49:35 +0000 Subject: [PATCH 4/7] fix: make CC1101 GET command response patterns more robust (including re.IGNORECASE) --- signalduino/commands.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/signalduino/commands.py b/signalduino/commands.py index 77d0a86..2a9318a 100644 --- a/signalduino/commands.py +++ b/signalduino/commands.py @@ -87,14 +87,14 @@ async def get_config(self, timeout: float = 2.0) -> Dict[str, int]: async def get_ccconf(self, timeout: float = 2.0) -> Dict[str, str]: """CC1101 configuration registers (C0DnF). Returns a dictionary with the raw string.""" # Response-Pattern aus 00_SIGNALduino.pm, Zeile 86, angepasst an Python regex - response = await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11=[A-F0-9a-f]+')) + response = await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11=[a-f0-9]+', re.IGNORECASE)) # Kapselt den rohen String, um die MQTT-Antwort konsistent als Dict zurückzugeben return {"cc1101_config_string": response} async def get_ccpatable(self, timeout: float = 2.0) -> str: """CC1101 PA table (C3E)""" # Response-Pattern aus 00_SIGNALduino.pm, Zeile 88 - return await self._send_command(command="C3E", expect_response=True, timeout=timeout, response_pattern=re.compile(r'^C3E\s=\s.*')) + return await self._send_command(command="C3E", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C3E\s=\s.*')) async def factory_reset(self, timeout: float = 5.0) -> Dict[str, str]: """Sets EEPROM defaults, effectively a factory reset (e). @@ -264,7 +264,7 @@ async def read_cc1101_register(self, register_address: int, timeout: float = 2.0 """Read CC1101 register (C)""" hex_addr = f"{register_address:02X}" # Response-Pattern: ccreg 00: oder Cxx = yy (aus 00_SIGNALduino.pm, Zeile 87) - return await self._send_command(command=f"C{hex_addr}", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C[A-Fa-f0-9]{2}\s=\s[0-9A-Fa-f]+|ccreg 00:')) + return await self._send_command(command=f"C{hex_addr}", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C[a-f0-9]{2}\s=\s[a-f0-9]+|ccreg 00:', re.IGNORECASE)) async def _get_frequency_registers(self) -> int: """Liest die CC1101 Frequenzregister (FREQ2, FREQ1, FREQ0) und kombiniert sie zu einem 24-Bit-Wert (F_REG).""" From 49a5bb0e8add7e1801b664210fd34bb844f70d4e Mon Sep 17 00:00:00 2001 From: sidey79 Date: Tue, 6 Jan 2026 18:54:08 +0000 Subject: [PATCH 5/7] fix: remove end-of-string anchor from CC1101 register value parser to handle trailing characters --- signalduino/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signalduino/commands.py b/signalduino/commands.py index 2a9318a..dc51d09 100644 --- a/signalduino/commands.py +++ b/signalduino/commands.py @@ -133,7 +133,7 @@ async def _read_register_value(self, register_address: int) -> int: """Liest einen CC1101-Registerwert und gibt ihn als Integer zurück.""" response = await self.read_cc1101_register(register_address) # Stellt sicher, dass wir nur den Wert nach 'C[A-Fa-f0-9]{2} = ' extrahieren - match = re.search(r'C[A-Fa-f0-9]{2}\s=\s([0-9A-Fa-f]+)$', response) + match = re.search(r'C[A-Fa-f0-9]{2}\s=\s([0-9A-Fa-f]+)', response, re.IGNORECASE) if match: return int(match.group(1), 16) # Fängt auch den Fall 'ccreg 00:' (default-Antwort) oder andere unerwartete Antworten ab From 5e3299478c99d20a8ff792fd04e01cd9efb5dcd9 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Tue, 6 Jan 2026 19:09:31 +0000 Subject: [PATCH 6/7] fix: make CC1101 register response patterns robust against variable whitespace --- signalduino/commands.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/signalduino/commands.py b/signalduino/commands.py index dc51d09..7fa8651 100644 --- a/signalduino/commands.py +++ b/signalduino/commands.py @@ -87,14 +87,14 @@ async def get_config(self, timeout: float = 2.0) -> Dict[str, int]: async def get_ccconf(self, timeout: float = 2.0) -> Dict[str, str]: """CC1101 configuration registers (C0DnF). Returns a dictionary with the raw string.""" # Response-Pattern aus 00_SIGNALduino.pm, Zeile 86, angepasst an Python regex - response = await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11=[a-f0-9]+', re.IGNORECASE)) + response = await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11\s*=\s*[a-f0-9]+', re.IGNORECASE)) # Kapselt den rohen String, um die MQTT-Antwort konsistent als Dict zurückzugeben return {"cc1101_config_string": response} async def get_ccpatable(self, timeout: float = 2.0) -> str: """CC1101 PA table (C3E)""" # Response-Pattern aus 00_SIGNALduino.pm, Zeile 88 - return await self._send_command(command="C3E", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C3E\s=\s.*')) + return await self._send_command(command="C3E", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C3E\s*=\s*.*')) async def factory_reset(self, timeout: float = 5.0) -> Dict[str, str]: """Sets EEPROM defaults, effectively a factory reset (e). @@ -133,7 +133,7 @@ async def _read_register_value(self, register_address: int) -> int: """Liest einen CC1101-Registerwert und gibt ihn als Integer zurück.""" response = await self.read_cc1101_register(register_address) # Stellt sicher, dass wir nur den Wert nach 'C[A-Fa-f0-9]{2} = ' extrahieren - match = re.search(r'C[A-Fa-f0-9]{2}\s=\s([0-9A-Fa-f]+)', response, re.IGNORECASE) + match = re.search(r'C[A-Fa-f0-9]{2}\s*=\s*([0-9A-Fa-f]+)', response, re.IGNORECASE) if match: return int(match.group(1), 16) # Fängt auch den Fall 'ccreg 00:' (default-Antwort) oder andere unerwartete Antworten ab @@ -264,7 +264,7 @@ async def read_cc1101_register(self, register_address: int, timeout: float = 2.0 """Read CC1101 register (C)""" hex_addr = f"{register_address:02X}" # Response-Pattern: ccreg 00: oder Cxx = yy (aus 00_SIGNALduino.pm, Zeile 87) - return await self._send_command(command=f"C{hex_addr}", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C[a-f0-9]{2}\s=\s[a-f0-9]+|ccreg 00:', re.IGNORECASE)) + return await self._send_command(command=f"C{hex_addr}", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C[a-f0-9]{2}\s*=\s*[a-f0-9]+|ccreg 00:', re.IGNORECASE)) async def _get_frequency_registers(self) -> int: """Liest die CC1101 Frequenzregister (FREQ2, FREQ1, FREQ0) und kombiniert sie zu einem 24-Bit-Wert (F_REG).""" From 408039c47e7fa900a7a31cbdfdde278b840b1cc1 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Wed, 7 Jan 2026 10:34:12 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20vereinheitliche=20die=20JSON-Antwort?= =?UTF-8?q?struktur=20f=C3=BCr=20CC1101-Parameter=20und=20verbessere=20Tim?= =?UTF-8?q?eout-Konstanten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .roo/mcp.json | 2 +- docs/01_user_guide/mqtt_api.adoc | 16 +-- ...-005-mqtt-cc1101-response-consistency.adoc | 49 +++++++++ signalduino/commands.py | 100 ++++++++++-------- signalduino/controller.py | 14 +-- tests/test_mqtt_commands.py | 97 ++++++++++------- 6 files changed, 180 insertions(+), 98 deletions(-) create mode 100644 docs/architecture/decisions/ADR-005-mqtt-cc1101-response-consistency.adoc diff --git a/.roo/mcp.json b/.roo/mcp.json index 14af10a..d539577 100644 --- a/.roo/mcp.json +++ b/.roo/mcp.json @@ -1 +1 @@ -{"mcpServers":{"filesystem":{"command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/workspaces/PySignalduino"],"alwaysAllow":["edit_file","read_text_file","search_files","read_multiple_files"]},"git":{"command":"uvx","args":["mcp-server-git","--repository","/workspaces/PySignalduino"],"alwaysAllow":["git_diff_unstaged","git_checkout"]}}} \ No newline at end of file +{"mcpServers":{"filesystem":{"command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/workspaces/PySignalduino"],"alwaysAllow":["edit_file","read_text_file","search_files","read_multiple_files","create_directory","list_directory","directory_tree"]},"git":{"command":"uvx","args":["mcp-server-git","--repository","/workspaces/PySignalduino"],"alwaysAllow":["git_diff_unstaged","git_checkout"]}}} \ No newline at end of file diff --git a/docs/01_user_guide/mqtt_api.adoc b/docs/01_user_guide/mqtt_api.adoc index 81e6db0..ce37a6f 100644 --- a/docs/01_user_guide/mqtt_api.adoc +++ b/docs/01_user_guide/mqtt_api.adoc @@ -136,35 +136,35 @@ GET-Befehle benötigen eine leere Payload (`{}`) oder nur eine `req_id`. | CC1101 Konfigurationsregister-Dump als gekapselter String. | `get/cc1101/patable` -| `"C3E = C0 C1 C2 C3 C4 C5 C6 C7"` +| `{"pa_table_hex": "C3E = C0 C1 C2 C3 C4 C5 C6 C7"}` | CC1101 PA-Tabelle. | `get/cc1101/register` -| `"C00 = 29"` +| `{"register_value": "C00 = 29"}` | Liest den Wert eines einzelnen CC1101-Registers (Adresse 0x00). Der Befehl nimmt keinen Wert in der Payload entgegen und liest standardmäßig Register 0x00. | `get/cc1101/frequency` -| `{"frequency_mhz": 868.3500}` +| `{"frequency": 868.3500}` | Aktuelle RF-Frequenz in MHz. | `get/cc1101/bandwidth` -| `102.0` +| `{"bandwidth": 102.0}` | Aktuelle IF-Bandbreite in kHz. | `get/cc1101/rampl` -| `30` +| `{"rampl": 30}` | Aktuelle Empfängerverstärkung (LNA Gain) in dB. Mögliche Werte: `24, 27, 30, 33, 36, 38, 40, 42`. | `get/cc1101/sensitivity` -| `12` +| `{"sensitivity": 12}` | Aktuelle Empfindlichkeit in dB. Mögliche Werte: `4, 8, 12, 16`. | `get/cc1101/datarate` -| `4.8` +| `{"datarate": 4.8}` | Aktuelle Datenrate in kBaud. | `get/cc1101/settings` -| `{"frequency_mhz": 868.35, "bandwidth": 102.0, "rampl": 30, "sens": 12, "datarate": 4.8}` +| `{"frequency": 868.35, "bandwidth": 102.0, "rampl": 30, "sensitivity": 12, "datarate": 4.8}` | Aggregierte Abfrage aller CC1101-Haupteinstellungen. |=== diff --git a/docs/architecture/decisions/ADR-005-mqtt-cc1101-response-consistency.adoc b/docs/architecture/decisions/ADR-005-mqtt-cc1101-response-consistency.adoc new file mode 100644 index 0000000..d1f7aa9 --- /dev/null +++ b/docs/architecture/decisions/ADR-005-mqtt-cc1101-response-consistency.adoc @@ -0,0 +1,49 @@ += ADR 005: Vereinheitlichung der MQTT-Antwortstruktur für CC1101-Parameter +:doctype: article :encoding: utf-8 :lang: de :status: Proposed :decided-at: 2026-01-07 :decided-by: Roo +:toc: left + +[[kontext]] +== Kontext + +Aktuell weichen die JSON-Antwortstrukturen für die Abfrage einzelner CC1101-Parameter via MQTT (z.B. Topic `get/cc1101/bandwidth`) von der Struktur der Gesamt-Abfrage (Topic `get/cc1101/settings`) ab. + +* **Aktuelle Einzelabfrage (angenommen):** `get/cc1101/bandwidth` -> `{"bandwidth": "X kHz"}` +* **Aktuelle Gesamtabfrage (angenommen):** `get/cc1101/settings` -> `{"cc1101": {"bandwidth": "X kHz", "rampl": "Y dbm", ...}}` + +Diese Inkonsistenz erschwert die automatisierte Verarbeitung der Antworten, da Clients je nach Abfragetyp unterschiedliche JSON-Pfade parsen müssen. Ziel ist eine konsistente Struktur, bei der die JSON-Knotennamen für die einzelnen Parameter in beiden Abfragetypen identisch sind. + +[[entscheidung]] +== Entscheidung + +Die JSON-Antwortstruktur für alle CC1101-Parameter-Abfragen wird vereinheitlicht. Die Schlüsselnamen der einzelnen Parameter in der JSON-Antwort werden in beiden Abfragetypen (Einzelparameter und Gesamt-Settings) identisch verwendet. Es wird entschieden, die Schlüssel der Einzelparameter ohne umschließendes Wrapper-Objekt zu verwenden. + +* **Antwort auf `get/cc1101/parameter` (z.B. `get/cc1101/bandwidth`):** + ```json + {"bandwidth": "X kHz"} + ``` +* **Antwort auf `get/cc1101/settings`:** + ```json + { + "bandwidth": "X kHz", + "rampl": "Y dbm", + "sensitivity": "Z", + "datarate": "A kbps" + } + ``` +Die `settings`-Antwort ist somit eine direkte Aggregation der Einzelparameter-Antworten. + +[[konsequenzen]] +== Konsequenzen + +=== Positive Konsequenzen +* **Konsistenz:** Vereinfacht das Parsen für MQTT-Clients, da die logischen Parameternamen (z.B. `bandwidth`) immer als JSON-Schlüssel auf der obersten Ebene der jeweiligen Antwort verwendet werden. +* **Wartbarkeit:** Reduziert die Komplexität in der Implementierung, da die Logik zur Generierung der Parameterdaten wiederverwendet werden kann. + +=== Negative Konsequenzen +* **Breaking Change:** Bestehende Clients, die sich auf eine Wrapper-Struktur wie `{"cc1101": {...}}` bei der Gesamt-Abfrage (`get/cc1101/settings`) verlassen, müssen angepasst werden. +* **Migration:** Die Server-Logik für die MQTT-Antworten in der PySignalduino-Implementierung muss entsprechend geändert werden. + +[[alternativen]] +== Alternativen +* **Alternative A: Wrapper in Einzelabfragen beibehalten:** Man könnte die Einzelabfrage um den CC1101-Wrapper erweitern (z.B. `get/cc1101/bandwidth` -> `{"cc1101": {"bandwidth": "X kHz"}}`). Dies wurde abgelehnt, da es unnötige Verschachtelung für Einzelwerte einführt und die Lesbarkeit des Payloads verschlechtert. +* **Alternative B: Einzelabfragen als reiner Wert:** Die Antwort könnte nur den reinen Wert zurückgeben (z.B. `get/cc1101/bandwidth` -> `"X kHz"`). Dies wurde abgelehnt, da es das JSON-Format verlässt und der Parametername im Payload verloren ginge, was die Eindeutigkeit erschwert. diff --git a/signalduino/commands.py b/signalduino/commands.py index 7fa8651..e42d050 100644 --- a/signalduino/commands.py +++ b/signalduino/commands.py @@ -8,6 +8,7 @@ from jsonschema import validate, ValidationError from signalduino.exceptions import CommandValidationError, SignalduinoCommandTimeout +from .constants import SDUINO_CMD_TIMEOUT if TYPE_CHECKING: # Importiere SignalduinoController nur für Type Hinting zur Kompilierzeit @@ -39,11 +40,11 @@ def _parse_decoder_config(self, response: str) -> Dict[str, int]: pass return config - async def get_version(self, timeout: float = 2.0) -> str: + async def get_version(self, timeout: float = SDUINO_CMD_TIMEOUT) -> str: """Firmware version (V)""" return await self._send_command(command="V", expect_response=True, timeout=timeout) - async def get_free_ram(self, timeout: float = 2.0) -> int: + async def get_free_ram(self, timeout: float = SDUINO_CMD_TIMEOUT) -> int: """Free RAM (R)""" # Firmware typically responds with a numeric value (e.g., "1234") response_pattern = re.compile(r'^(\d+)$') @@ -54,7 +55,7 @@ async def get_free_ram(self, timeout: float = 2.0) -> int: return int(match.group(1)) raise ValueError(f"Unexpected response format for Free RAM: {response}") - async def get_uptime(self, timeout: float = 2.0) -> int: + async def get_uptime(self, timeout: float = SDUINO_CMD_TIMEOUT) -> int: """System uptime (t)""" # Firmware typically responds with a numeric value (e.g., "1234") response_pattern = re.compile(r'^(\d+)$') @@ -65,17 +66,17 @@ async def get_uptime(self, timeout: float = 2.0) -> int: return int(match.group(1)) raise ValueError(f"Unexpected response format for Uptime: {response}") - async def get_cmds(self, timeout: float = 2.0) -> str: + async def get_cmds(self, timeout: float = SDUINO_CMD_TIMEOUT) -> str: """Available commands (?)""" return await self._send_command(command="?", expect_response=True, timeout=timeout) - async def ping(self, timeout: float = 2.0) -> str: + async def ping(self, timeout: float = SDUINO_CMD_TIMEOUT) -> str: """Ping (P)""" return await self._send_command(command="P", expect_response=True, timeout=timeout) - async def get_config(self, timeout: float = 2.0) -> Dict[str, int]: + async def get_config(self, timeout: float = SDUINO_CMD_TIMEOUT) -> Dict[str, int]: """Decoder configuration (CG) - Returns parsed dictionary.""" - config_pattern = re.compile(r'^MS=[01];MU=[01];MC=[01];Mred=[01](;M[A-Za-z0-9]+=[01])*$') + config_pattern = re.compile(r'^\s*([A-Za-z0-9]+=\d+;?)+\s*$', re.IGNORECASE) response = await self._send_command( command="CG", expect_response=True, @@ -84,19 +85,20 @@ async def get_config(self, timeout: float = 2.0) -> Dict[str, int]: ) return self._parse_decoder_config(response) - async def get_ccconf(self, timeout: float = 2.0) -> Dict[str, str]: + async def get_ccconf(self, timeout: float = SDUINO_CMD_TIMEOUT) -> Dict[str, str]: """CC1101 configuration registers (C0DnF). Returns a dictionary with the raw string.""" # Response-Pattern aus 00_SIGNALduino.pm, Zeile 86, angepasst an Python regex - response = await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11\s*=\s*[a-f0-9]+', re.IGNORECASE)) + response = await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'^\s*C0D\w*\s*=\s*.*$', re.IGNORECASE)) # Kapselt den rohen String, um die MQTT-Antwort konsistent als Dict zurückzugeben return {"cc1101_config_string": response} - async def get_ccpatable(self, timeout: float = 2.0) -> str: + async def get_ccpatable(self, timeout: float = SDUINO_CMD_TIMEOUT) -> Dict[str, str]: """CC1101 PA table (C3E)""" # Response-Pattern aus 00_SIGNALduino.pm, Zeile 88 - return await self._send_command(command="C3E", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C3E\s*=\s*.*')) + response = await self._send_command(command="C3E", expect_response=True, timeout=timeout, response_pattern=re.compile(r'^\s*C3E\s*=\s*.*\s*$', re.IGNORECASE)) + return {"pa_table_hex": response} - async def factory_reset(self, timeout: float = 5.0) -> Dict[str, str]: + async def factory_reset(self, timeout: float = SDUINO_CMD_TIMEOUT) -> Dict[str, str]: """Sets EEPROM defaults, effectively a factory reset (e). This command does not send a response unless debug mode is active. We treat the command @@ -113,33 +115,35 @@ async def get_cc1101_settings(self, payload: Optional[Dict[str, Any]] = None) -> """ # Alle benötigten Getter existieren bereits in SignalduinoCommands freq_result = await self.get_frequency(payload) - bandwidth = await self.get_bandwidth(payload) - rampl = await self.get_rampl(payload) - sens = await self.get_sensitivity(payload) - datarate = await self.get_data_rate(payload) + bandwidth_result = await self.get_bandwidth(payload) + rampl_result = await self.get_rampl(payload) + sens_result = await self.get_sensitivity(payload) + datarate_result = await self.get_data_rate(payload) return { - # Flatten the frequency structure - "frequency_mhz": freq_result["frequency_mhz"], - "bandwidth": bandwidth, - "rampl": rampl, - "sens": sens, - "datarate": datarate, + "frequency_mhz": freq_result["frequency"], + "bandwidth": bandwidth_result["bandwidth"], + "rampl": rampl_result["rampl"], + "sensitivity": sens_result["sensitivity"], + "datarate": datarate_result["datarate"], } # --- CC1101 Hardware Status GET-Methoden (Basierend auf 00_SIGNALduino.pm) --- async def _read_register_value(self, register_address: int) -> int: """Liest einen CC1101-Registerwert und gibt ihn als Integer zurück.""" - response = await self.read_cc1101_register(register_address) - # Stellt sicher, dass wir nur den Wert nach 'C[A-Fa-f0-9]{2} = ' extrahieren - match = re.search(r'C[A-Fa-f0-9]{2}\s*=\s*([0-9A-Fa-f]+)', response, re.IGNORECASE) + response_dict = await self.read_cc1101_register(register_address) + response = response_dict["register_value"] + + # Stellt sicher, dass wir nur den Wert nach 'C[A-Fa-f0-9]{2}\s*=\s*([0-9A-Fa-f]+)' extrahieren + # Hinzufügen von \s* um die Werte herum, um Whitespace-Toleranz zu erhöhen. + match = re.search(r'C[A-Fa-f0-9]{2}\s*=\s*([0-9A-Fa-f]+)\s*', response, re.IGNORECASE) if match: return int(match.group(1), 16) # Fängt auch den Fall 'ccreg 00:' (default-Antwort) oder andere unerwartete Antworten ab raise ValueError(f"Unexpected response format for CC1101 register read: {response}") - async def get_bandwidth(self, payload: Optional[Dict[str, Any]] = None) -> float: + async def get_bandwidth(self, payload: Optional[Dict[str, Any]] = None) -> Dict[str, float]: """Liest die CC1101 Bandbreitenregister (MDMCFG4/0x10) und berechnet die Bandbreite in kHz.""" r10 = await self._read_register_value(0x10) # MDMCFG4 @@ -150,9 +154,9 @@ async def get_bandwidth(self, payload: Optional[Dict[str, Any]] = None) -> float # Frequenz (FXOSC) ist 26 MHz (26000 kHz) bandwidth_khz = 26000.0 / (8.0 * (4.0 + mant_b) * (1 << exp_b)) - return round(bandwidth_khz, 3) + return {"bandwidth": round(bandwidth_khz, 3)} - async def get_rampl(self, payload: Optional[Dict[str, Any]] = None) -> int: + async def get_rampl(self, payload: Optional[Dict[str, Any]] = None) -> Dict[str, int]: """Liest die CC1101 Verstärkungsregister (AGCCTRL0/0x1B) und gibt die Verstärkung in dB zurück.""" r1b = await self._read_register_value(0x1B) # AGCCTRL0 @@ -164,13 +168,15 @@ async def get_rampl(self, payload: Optional[Dict[str, Any]] = None) -> int: index = r1b & 7 if index < len(ampllist): - return ampllist[index] + rampl_db = ampllist[index] else: # Dies sollte nicht passieren, wenn die CC1101-Registerwerte korrekt sind logger.warning("Invalid AGC_LNA_GAIN setting found in 0x1B: %s", index) - return -1 # Fehlerwert + rampl_db = -1 # Fehlerwert + + return {"rampl": rampl_db} - async def get_sensitivity(self, payload: Optional[Dict[str, Any]] = None) -> int: + async def get_sensitivity(self, payload: Optional[Dict[str, Any]] = None) -> Dict[str, int]: """Liest die CC1101 Empfindlichkeitsregister (RSSIAGC/0x1D) und gibt die Empfindlichkeit in dB zurück.""" r1d = await self._read_register_value(0x1D) # RSSIAGC (0x1D) @@ -178,9 +184,9 @@ async def get_sensitivity(self, payload: Optional[Dict[str, Any]] = None) -> int # Die unteren 2 Bits enthalten den LNA-Modus (LNA_PD_BUF) sens_db = 4 + 4 * (r1d & 3) - return sens_db + return {"sensitivity": sens_db} - async def get_data_rate(self, payload: Optional[Dict[str, Any]] = None) -> float: + async def get_data_rate(self, payload: Optional[Dict[str, Any]] = None) -> Dict[str, float]: """Liest die CC1101 Datenratenregister (MDMCFG4/0x10 und MDMCFG3/0x11) und berechnet die Datenrate in kBaud.""" r10 = await self._read_register_value(0x10) # MDMCFG4 r11 = await self._read_register_value(0x11) # MDMCFG3 @@ -201,7 +207,7 @@ async def get_data_rate(self, payload: Optional[Dict[str, Any]] = None) -> float # Umrechnung in kBaud (kiloBaud = kiloBits pro Sekunde) data_rate_kbaud = data_rate_hz / 1000.0 - return round(data_rate_kbaud, 2) + return {"datarate": round(data_rate_kbaud, 2)} def _calculate_datarate_registers(self, datarate_kbaud: float) -> tuple[int, int]: """ @@ -260,11 +266,17 @@ def _calculate_datarate_registers(self, datarate_kbaud: float) -> tuple[int, int return best_drate_e, best_drate_m - async def read_cc1101_register(self, register_address: int, timeout: float = 2.0) -> str: + async def read_cc1101_register(self, register_address: int, timeout: float = SDUINO_CMD_TIMEOUT) -> Dict[str, str]: """Read CC1101 register (C)""" hex_addr = f"{register_address:02X}" # Response-Pattern: ccreg 00: oder Cxx = yy (aus 00_SIGNALduino.pm, Zeile 87) - return await self._send_command(command=f"C{hex_addr}", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C[a-f0-9]{2}\s*=\s*[a-f0-9]+|ccreg 00:', re.IGNORECASE)) + # Die Regex muss an den Anfang und das Ende der Zeile gebunden werden (re.match wird verwendet) + # ^(C[a-f0-9]{2}\s*=\s*[a-f0-9]+|ccreg 00:.*)\s*$ + # Hinweis: *Der Controller verwendet re.match*, was implizit ^ bindet. + # Wir müssen den Regex also an das Ende binden, um Leerzeichen zu erlauben. + response_pattern = re.compile(r'^\s*(C[a-f0-9]{2}\s*=\s*[a-f0-9]+|ccreg [a-f0-9]{2}:.*)\s*$', re.IGNORECASE) + response = await self._send_command(command=f"C{hex_addr}", expect_response=True, timeout=timeout, response_pattern=response_pattern) + return {"register_value": response} async def _get_frequency_registers(self) -> int: """Liest die CC1101 Frequenzregister (FREQ2, FREQ1, FREQ0) und kombiniert sie zu einem 24-Bit-Wert (F_REG).""" @@ -276,8 +288,8 @@ async def _get_frequency_registers(self) -> int: # Funktion zum Extrahieren des Hex-Werts aus der Antwort: Cxx = def extract_hex_value(response: str) -> int: - # Stellt sicher, dass wir nur den Wert nach 'C[A-Fa-f0-9]{2} = ' extrahieren - match = re.search(r'C[A-Fa-f0-9]{2}\s=\s([0-9A-Fa-f]+)$', response) + # Stellt sicher, dass wir nur den Wert nach 'C[A-Fa-f0-9]{2}\s=\s([0-9A-Fa-f]+)$' extrahieren + match = re.search(r'C[A-Fa-f0-9]{2}\s*=\s*([0-9A-Fa-f]+)\s*$', response) if match: return int(match.group(1), 16) # Fängt auch den Fall 'ccreg 00:' (default-Antwort) oder andere unerwartete Antworten ab @@ -285,15 +297,15 @@ def extract_hex_value(response: str) -> int: # FREQ2 (0D) response2 = await self.read_cc1101_register(FREQ2) - freq2 = extract_hex_value(response2) + freq2 = extract_hex_value(response2["register_value"]) # FREQ1 (0E) response1 = await self.read_cc1101_register(FREQ1) - freq1 = extract_hex_value(response1) + freq1 = extract_hex_value(response1["register_value"]) # FREQ0 (0F) response0 = await self.read_cc1101_register(FREQ0) - freq0 = extract_hex_value(response0) + freq0 = extract_hex_value(response0["register_value"]) # Die Register bilden eine 24-Bit-Zahl: (FREQ2 << 16) | (FREQ1 << 8) | FREQ0 f_reg = (freq2 << 16) | (freq1 << 8) | freq0 @@ -317,14 +329,14 @@ async def get_frequency(self, payload: Optional[Dict[str, Any]] = None) -> Dict[ # Rückgabe des gekapselten und auf 4 Dezimalstellen gerundeten Wertes, wie in tests/test_mqtt.py erwartet. return { - "frequency_mhz": round(frequency_mhz, 4) + "frequency": round(frequency_mhz, 4) } - async def send_raw_message(self, command: str, timeout: float = 2.0) -> str: + async def send_raw_message(self, command: str, timeout: float = SDUINO_CMD_TIMEOUT) -> str: """Send raw message (M...)""" return await self._send_command(command=command, expect_response=True, timeout=timeout) - async def send_message(self, message: str, timeout: float = 2.0) -> None: + async def send_message(self, message: str, timeout: float = SDUINO_CMD_TIMEOUT) -> None: """Send a pre-encoded message (P...#R...). This is typically used for 'set raw' commands where the message is already fully formatted. NOTE: This sends the message AS IS, without any wrapping like 'set raw '. diff --git a/signalduino/controller.py b/signalduino/controller.py index 76a93e6..a4791e1 100644 --- a/signalduino/controller.py +++ b/signalduino/controller.py @@ -105,7 +105,7 @@ async def get_ccconf(self, payload: Dict[str, Any]) -> Dict[str, str]: """Delegates to SignalduinoCommands to get the CC1101 config registers (C0DnF).""" return await self.commands.get_ccconf() - async def get_ccpatable(self, payload: Dict[str, Any]) -> str: + async def get_ccpatable(self, payload: Dict[str, Any]) -> Dict[str, str]: """Delegates to SignalduinoCommands to get the CC1101 PA table (C3E).""" return await self.commands.get_ccpatable() @@ -119,19 +119,19 @@ async def factory_reset(self, payload: Dict[str, Any]) -> Dict[str, str]: # Payload wird zur Validierung akzeptiert, aber ignoriert. return await self.commands.factory_reset() - async def get_bandwidth(self, payload: Dict[str, Any]) -> float: + async def get_bandwidth(self, payload: Dict[str, Any]) -> Dict[str, float]: """Delegates to SignalduinoCommands to get the current CC1101 bandwidth in kHz.""" return await self.commands.get_bandwidth(payload) - async def get_rampl(self, payload: Dict[str, Any]) -> int: + async def get_rampl(self, payload: Dict[str, Any]) -> Dict[str, int]: """Delegates to SignalduinoCommands to get the current CC1101 receiver amplification in dB.""" return await self.commands.get_rampl(payload) - async def get_sensitivity(self, payload: Dict[str, Any]) -> int: + async def get_sensitivity(self, payload: Dict[str, Any]) -> Dict[str, int]: """Delegates to SignalduinoCommands to get the current CC1101 sensitivity in dB.""" return await self.commands.get_sensitivity(payload) - async def get_data_rate(self, payload: Dict[str, Any]) -> float: + async def get_data_rate(self, payload: Dict[str, Any]) -> Dict[str, float]: """Delegates to SignalduinoCommands to get the current CC1101 data rate in kBaud.""" return await self.commands.get_data_rate(payload) @@ -230,7 +230,7 @@ async def _reader_task(self) -> None: self.logger.debug("Reader task waiting for line...") line = await self.transport.readline() if line is not None: - self.logger.debug(f"Reader task received line: {line}") + self.logger.debug("RAW LINE from transport: %s", line) await self._raw_message_queue.put(line) await asyncio.sleep(0.01) # Ensure minimal yield time to prevent 100% CPU usage @@ -355,7 +355,7 @@ async def _send_and_wait(self, command: str, timeout: float, response_pattern: O async def _handle_as_command_response(self, line: str) -> None: """Check if the received line matches any pending command response.""" - self.logger.debug(f"Checking line for command response: {line}") + self.logger.debug("Hardware response received: %s", line) async with self._pending_responses_lock: self.logger.debug(f"Current pending responses: {len(self._pending_responses)}") for pending in self._pending_responses: diff --git a/tests/test_mqtt_commands.py b/tests/test_mqtt_commands.py index 08feedf..d807fe9 100644 --- a/tests/test_mqtt_commands.py +++ b/tests/test_mqtt_commands.py @@ -15,7 +15,7 @@ from signalduino.commands import SignalduinoCommands from signalduino.exceptions import SignalduinoCommandTimeout from signalduino.controller import QueuedCommand # Import QueuedCommand - +from signalduino.constants import SDUINO_CMD_TIMEOUT # Constants INTERLEAVED_MESSAGE = "MU;P0=353;P1=-184;D=0123456789;CP=1;SP=0;R=248;\n" @@ -152,7 +152,7 @@ async def test_controller_handles_freeram_command(signalduino_controller, mock_a send_command_mock.assert_called_once_with( command='R', expect_response=True, - timeout=2.0, + timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern ) @@ -188,7 +188,7 @@ async def test_controller_handles_uptime_command(signalduino_controller, mock_ai send_command_mock.assert_called_once_with( command='t', expect_response=True, - timeout=2.0, + timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern ) @@ -242,10 +242,12 @@ async def test_controller_handles_config_command(signalduino_controller, mock_ai assert result['data'] == {'MS': 1, 'MU': 1, 'MC': 1, 'MN': 1} # 4. Überprüfe, ob send_command mit dem korrekten Befehl aufgerufen wurde + expected_pattern = re.compile(r'^\s*([A-Za-z0-9]+=\d+;?)+\s*$', re.IGNORECASE) send_command_mock.assert_called_once_with( command='CG', expect_response=True, - timeout=2.0 + timeout=SDUINO_CMD_TIMEOUT, + response_pattern=expected_pattern ) @pytest.mark.asyncio @@ -254,7 +256,7 @@ async def test_controller_handles_ccconf_command(signalduino_controller, mock_ai Test handling of the 'ccconf' command, expecting the raw string wrapped in a dictionary. """ # 1. Mock _send_command - raw_response_line = "C0D11=0F" # Die Rohantwort vom Controller ist ohne \n + raw_response_line = "C0Dn11=105B1A57C43023B900070018146C070091" # Realistische Hardware-Antwort send_command_mock = AsyncMock(return_value=raw_response_line) signalduino_controller.commands._send_command = send_command_mock @@ -271,14 +273,14 @@ async def test_controller_handles_ccconf_command(signalduino_controller, mock_ai assert result['status'] == "OK" assert result['req_id'] == "test_ccconf" # Erwartet den gekapselten String - assert result['data'] == {'cc1101_config_string': 'C0D11=0F'} + assert result['data'] == {'cc1101_config_string': 'C0Dn11=105B1A57C43023B900070018146C070091'} # 4. Überprüfe, ob send_command mit dem korrekten Befehl und Pattern aufgerufen wurde - expected_pattern = re.compile(r'C0Dn11=[A-F0-9a-f]+') + expected_pattern = re.compile(r'^\s*C0D\w*\s*=\s*.*$', re.IGNORECASE) send_command_mock.assert_called_once_with( command='C0DnF', expect_response=True, - timeout=2.0, + timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern ) @@ -286,13 +288,33 @@ async def test_controller_handles_ccconf_command(signalduino_controller, mock_ai async def test_controller_handles_ccpatable_command(signalduino_controller, mock_aiomqtt_client_cls): """Test handling of the 'ccpatable' command.""" # The regex r"^C3E\s=\s.*" expects the beginning of the line. + # 1. Mock _send_command + raw_response_line = "C3E = C0 C1 C2 C3 C4 C5 C6 C7\n" + send_command_mock = AsyncMock(return_value=raw_response_line.strip()) + signalduino_controller.commands._send_command = send_command_mock + + # 2. Dispatcher und Payload vorbereiten + command_path = "get/cc1101/patable" + mqtt_payload = '{"req_id": "test_patable"}' + + dispatcher = MqttCommandDispatcher(controller=signalduino_controller) + async with signalduino_controller: - await run_mqtt_command_test( - signalduino_controller, - mock_aiomqtt_client_cls, - mqtt_cmd="ccpatable", - raw_cmd="C3E", - expected_response_line="C3E = C0 C1 C2 C3 C4 C5 C6 C7\n" + result = await dispatcher.dispatch(command_path, mqtt_payload) + + # 3. Assertions + assert result['status'] == "OK" + assert result['req_id'] == "test_patable" + # Erwartet den gekapselten String + assert result['data'] == {'pa_table_hex': 'C3E = C0 C1 C2 C3 C4 C5 C6 C7'} + + # 4. Überprüfe, ob send_command mit dem korrekten Befehl und Pattern aufgerufen wurde + expected_pattern = re.compile(r'^\s*C3E\s*=\s*.*\s*$', re.IGNORECASE) + send_command_mock.assert_called_once_with( + command='C3E', + expect_response=True, + timeout=SDUINO_CMD_TIMEOUT, + response_pattern=expected_pattern ) @pytest.mark.asyncio @@ -374,16 +396,16 @@ async def test_controller_handles_get_frequency(signalduino_controller, mock_aio assert result['status'] == "OK" assert result['req_id'] == "test_freq" # Überprüfe den berechneten Frequenzwert - # result['data'] ist jetzt {'frequency_mhz': float}, da commands.get_frequency geändert wurde. - assert result['data']['frequency_mhz'] == expected_frequency_rounded + # result['data'] ist jetzt {'frequency': float}, da commands.get_frequency geändert wurde. + assert result['data']['frequency'] == expected_frequency_rounded # Überprüfe, ob send_command mit den korrekten Argumenten aufgerufen wurde - expected_pattern = re.compile(r'C[A-Fa-f0-9]{2}\s=\s[0-9A-Fa-f]+$|ccreg 00:') + expected_pattern = re.compile(r'^\s*(C[a-f0-9]{2}\s*=\s*[a-f0-9]+|ccreg [a-f0-9]{2}:.*)\s*$', re.IGNORECASE) send_command_mock.assert_has_calls([ - call(command='C0D', expect_response=True, timeout=2.0, response_pattern=expected_pattern), - call(command='C0E', expect_response=True, timeout=2.0, response_pattern=expected_pattern), - call(command='C0F', expect_response=True, timeout=2.0, response_pattern=expected_pattern), + call(command='C0D', expect_response=True, timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern), + call(command='C0E', expect_response=True, timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern), + call(command='C0F', expect_response=True, timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern), ]) @pytest.mark.asyncio @@ -432,15 +454,15 @@ async def test_controller_handles_get_frequency_without_req_id(signalduino_contr assert result['status'] == "OK" assert result['req_id'] is None # <- CRITICAL: Überprüfe, dass req_id None ist - assert result['data']['frequency_mhz'] == expected_frequency_rounded + assert result['data']['frequency'] == expected_frequency_rounded # Überprüfe, ob send_command mit den korrekten Argumenten aufgerufen wurde (gleiche Calls wie zuvor) - expected_pattern = re.compile(r'C[A-Fa-f0-9]{2}\s=\s[0-9A-Fa-f]+$|ccreg 00:') + expected_pattern = re.compile(r'^\s*(C[a-f0-9]{2}\s*=\s*[a-f0-9]+|ccreg [a-f0-9]{2}:.*)\s*$', re.IGNORECASE) send_command_mock.assert_has_calls([ - call(command='C0D', expect_response=True, timeout=2.0, response_pattern=expected_pattern), - call(command='C0E', expect_response=True, timeout=2.0, response_pattern=expected_pattern), - call(command='C0F', expect_response=True, timeout=2.0, response_pattern=expected_pattern), + call(command='C0D', expect_response=True, timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern), + call(command='C0E', expect_response=True, timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern), + call(command='C0F', expect_response=True, timeout=SDUINO_CMD_TIMEOUT, response_pattern=expected_pattern), ]) @pytest.mark.asyncio @@ -466,11 +488,11 @@ async def test_controller_handles_set_factory_reset(signalduino_controller, mock assert result['data'] == {'status': 'Reset command sent', 'info': 'Factory reset triggered'} # 2. Assertions für den gesendeten Befehl (e) - # Der Timeout für factory_reset ist 5.0 + # Der Timeout für factory_reset ist SDUINO_CMD_TIMEOUT send_command_mock.assert_called_once_with( command='e', expect_response=False, - timeout=5.0 + timeout=SDUINO_CMD_TIMEOUT ) @@ -488,25 +510,24 @@ async def test_controller_handles_get_cc1101_settings(signalduino_controller, mo # Wir müssen die Methoden im Commands-Objekt überschreiben. # get_frequency gibt ein geschachteltes Dict zurück, das von get_cc1101_settings abgeflacht wird. - freq_mock = AsyncMock(return_value={"frequency_mhz": 868.35}) + freq_mock = AsyncMock(return_value={"frequency": 868.35}) signalduino_controller.commands.get_frequency = freq_mock - # 2. Bandbreiten-Mock - bw_mock = AsyncMock(return_value=102.0) + # 2. Bandbreiten-Mock (liefert jetzt Dict) + bw_mock = AsyncMock(return_value={"bandwidth": 102.0}) signalduino_controller.commands.get_bandwidth = bw_mock - # 3. RAMPL-Mock - rampl_mock = AsyncMock(return_value=30) + # 3. RAMPL-Mock (liefert jetzt Dict) + rampl_mock = AsyncMock(return_value={"rampl": 30}) signalduino_controller.commands.get_rampl = rampl_mock - # 4. Sensitivity-Mock - sens_mock = AsyncMock(return_value=12) + # 4. Sensitivity-Mock (liefert jetzt Dict) + sens_mock = AsyncMock(return_value={"sensitivity": 12}) signalduino_controller.commands.get_sensitivity = sens_mock - # 5. Data Rate-Mock - dr_mock = AsyncMock(return_value=4.8) + # 5. Data Rate-Mock (liefert jetzt Dict) + dr_mock = AsyncMock(return_value={"datarate": 4.8}) signalduino_controller.commands.get_data_rate = dr_mock - # Dispatcher und Payload vorbereiten command_path = "get/cc1101/settings" mqtt_payload = '{"req_id": "test_settings"}' @@ -525,7 +546,7 @@ async def test_controller_handles_get_cc1101_settings(signalduino_controller, mo "frequency_mhz": 868.35, "bandwidth": 102.0, "rampl": 30, - "sens": 12, + "sensitivity": 12, "datarate": 4.8, }