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 4fc18c6..ce37a6f 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,51 +120,51 @@ 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"`
+| `{"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-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/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 243f298..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
@@ -24,41 +25,80 @@ def __init__(self, send_command: Callable[..., Awaitable[Any]], mqtt_topic_root:
self._send_command = send_command
self.mqtt_topic_root = mqtt_topic_root
- async def get_version(self, timeout: float = 2.0) -> str:
+ 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 = 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) -> str:
+ async def get_free_ram(self, timeout: float = SDUINO_CMD_TIMEOUT) -> 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 = SDUINO_CMD_TIMEOUT) -> 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)
- async def get_cmds(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 Uptime: {response}")
+
+ 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) -> str:
- """Decoder configuration (CG)"""
- return await self._send_command(command="CG", expect_response=True, timeout=timeout)
-
- async def get_ccconf(self, timeout: float = 2.0) -> str:
- """CC1101 configuration registers (C0DnF)"""
+ async def get_config(self, timeout: float = SDUINO_CMD_TIMEOUT) -> Dict[str, int]:
+ """Decoder configuration (CG) - Returns parsed dictionary."""
+ config_pattern = re.compile(r'^\s*([A-Za-z0-9]+=\d+;?)+\s*$', re.IGNORECASE)
+ 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 = 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
- return await self._send_command(command="C0DnF", expect_response=True, timeout=timeout, response_pattern=re.compile(r'C0Dn11=[A-F0-9a-f]+'))
-
- async def get_ccpatable(self, timeout: float = 2.0) -> str:
+ 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 = 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
@@ -75,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)
+ 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
@@ -112,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
@@ -126,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)
@@ -140,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
@@ -163,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]:
"""
@@ -222,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-Fa-f0-9]{2}\s=\s[0-9A-Fa-f]+$|ccreg 00:'))
+ # 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)."""
@@ -238,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
@@ -247,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
@@ -279,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 '.
@@ -501,12 +551,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..a4791e1 100644
--- a/signalduino/controller.py
+++ b/signalduino/controller.py
@@ -89,29 +89,49 @@ 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]) -> Dict[str, 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()
- 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)
@@ -210,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
@@ -335,22 +355,25 @@ 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:
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
diff --git a/tests/test_mqtt_commands.py b/tests/test_mqtt_commands.py
index 3fdaffb..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"
@@ -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=SDUINO_CMD_TIMEOUT,
+ 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=SDUINO_CMD_TIMEOUT,
+ response_pattern=expected_pattern
)
@pytest.mark.asyncio
@@ -170,42 +218,103 @@ 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
+ 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=SDUINO_CMD_TIMEOUT,
+ response_pattern=expected_pattern
)
@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 = "C0Dn11=105B1A57C43023B900070018146C070091" # Realistische Hardware-Antwort
+ 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': 'C0Dn11=105B1A57C43023B900070018146C070091'}
+
+ # 4. Überprüfe, ob send_command mit dem korrekten Befehl und Pattern aufgerufen wurde
+ 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=SDUINO_CMD_TIMEOUT,
+ response_pattern=expected_pattern
)
@pytest.mark.asyncio
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
@@ -287,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
@@ -345,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
@@ -379,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
)
@@ -401,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"}'
@@ -438,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,
}
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