Skip to content

Commit 1215a6c

Browse files
committed
feat: additional commands and more robust ones
1 parent 9bdfe70 commit 1215a6c

4 files changed

Lines changed: 297 additions & 105 deletions

File tree

signalduino/commands.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,34 @@ def __init__(self, send_command_func: Callable[[str, bool, float, Optional[Patte
2626

2727
def get_version(self, timeout: float = 2.0) -> str:
2828
"""Query firmware version (V)."""
29-
pattern = re.compile(r"V\s.*SIGNAL(?:duino|ESP|STM).*", re.IGNORECASE)
29+
pattern = re.compile(r"V\s.*SIGNAL(?:duino|ESP|STM).*(?:\s\d\d:\d\d:\d\d)", re.IGNORECASE)
3030
return self._send("V", expect_response=True, timeout=timeout, response_pattern=pattern)
3131

3232
def get_help(self) -> str:
3333
"""Show help (?)."""
34+
# This is for internal use/legacy. The MQTT 'cmds' command uses a specific pattern.
3435
return self._send("?", expect_response=True, timeout=2.0, response_pattern=None)
3536

37+
def get_cmds(self) -> str:
38+
"""Show help/commands (?). Used for MQTT 'cmds' command."""
39+
pattern = re.compile(r".*")
40+
return self._send("?", expect_response=True, timeout=2.0, response_pattern=pattern)
41+
3642
def get_free_ram(self) -> str:
3743
"""Query free RAM (R)."""
3844
# Response is typically a number (bytes)
39-
pattern = re.compile(r"^\d+$")
45+
pattern = re.compile(r"^[0-9]+")
4046
return self._send("R", expect_response=True, timeout=2.0, response_pattern=pattern)
4147

4248
def get_uptime(self) -> str:
4349
"""Query uptime in seconds (t)."""
4450
# Response is a number (seconds)
45-
pattern = re.compile(r"^\d+$")
51+
pattern = re.compile(r"^[0-9]+")
4652
return self._send("t", expect_response=True, timeout=2.0, response_pattern=pattern)
4753

4854
def ping(self) -> str:
4955
"""Ping device (P)."""
50-
return self._send("P", expect_response=True, timeout=2.0, response_pattern=re.compile(r"OK"))
56+
return self._send("P", expect_response=True, timeout=2.0, response_pattern=re.compile(r"^OK$"))
5157

5258
def get_cc1101_status(self) -> str:
5359
"""Query CC1101 status (s)."""
@@ -70,7 +76,7 @@ def factory_reset(self) -> str:
7076
def get_config(self) -> str:
7177
"""Read configuration (CG)."""
7278
# Response format: MS=1;MU=1;...
73-
pattern = re.compile(r"^MS=.*")
79+
pattern = re.compile(r"^M[S|N]=.*")
7480
return self._send("CG", expect_response=True, timeout=2.0, response_pattern=pattern)
7581

7682
def set_decoder_state(self, decoder: str, enabled: bool) -> None:
@@ -121,10 +127,24 @@ def set_message_type_enabled(self, message_type: str, enabled: bool) -> None:
121127
command = f"C{flag_char}{cmd_char}"
122128
self._send(command, expect_response=False, timeout=0, response_pattern=None)
123129

130+
def get_ccconf(self) -> str:
131+
"""Query CC1101 configuration (C0DnF)."""
132+
# Response format: C0Dnn=[A-F0-9a-f]+ (e.g., C0D11=0F)
133+
pattern = re.compile(r"C0Dn11=[A-F0-9a-f]+")
134+
return self._send("C0DnF", expect_response=True, timeout=2.0, response_pattern=pattern)
135+
136+
def get_ccpatable(self) -> str:
137+
"""Query CC1101 PA Table (C3E)."""
138+
# Response format: C3E = ...
139+
pattern = re.compile(r"^C3E\s=\s.*")
140+
return self._send("C3E", expect_response=True, timeout=2.0, response_pattern=pattern)
141+
124142
def read_cc1101_register(self, register: int) -> str:
125143
"""Read CC1101 register (C<reg>). Register is int, sent as 2-digit hex."""
126144
reg_hex = f"{register:02X}"
127-
return self._send(f"C{reg_hex}", expect_response=True, timeout=2.0, response_pattern=None)
145+
# Response format: Cnn = vv or ccreg 00: ...
146+
pattern = re.compile(r"^(?:C[A-Fa-f0-9]{2}\s=\s[0-9A-Fa-f]+$|ccreg 00:)")
147+
return self._send(f"C{reg_hex}", expect_response=True, timeout=2.0, response_pattern=pattern)
128148

129149
def write_register(self, register: int, value: int) -> str:
130150
"""Write to EEPROM/CC1101 register (W<reg><val>)."""
@@ -189,6 +209,13 @@ def send_raw(self, params: str) -> None:
189209
"""Send Raw (SR...). params should be the full string after SR."""
190210
self._send(f"SR{params}", expect_response=False, timeout=0, response_pattern=None)
191211

212+
def send_raw_message(self, message: str) -> str:
213+
"""Send the raw message/command directly as payload. Expects a response."""
214+
# The 'rawmsg' MQTT command sends the content of the payload directly as a command.
215+
# It is assumed that it will get a response which is why we expect one.
216+
# No specific pattern can be given here, rely on the default response matchers.
217+
return self._send(message, expect_response=True, timeout=2.0, response_pattern=None)
218+
192219
def send_xfsk(self, params: str) -> None:
193220
"""Send xFSK (SN...). params should be the full string after SN."""
194221
self._send(f"SN{params}", expect_response=False, timeout=0, response_pattern=None)

signalduino/controller.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@ def _parser_loop(self) -> None:
235235

236236
line_data = raw_line.strip()
237237

238-
if self._handle_as_command_response(line_data):
238+
# Messages starting with \x02 (STX) are sensor data and should never be treated as command responses.
239+
# They are passed directly to the parser.
240+
if line_data.startswith("\x02"):
241+
pass # Skip _handle_as_command_response and go to parsing
242+
elif self._handle_as_command_response(line_data):
239243
continue
240244

241245
if line_data.startswith("XQ") or line_data.startswith("XR"):
@@ -472,17 +476,41 @@ def _handle_mqtt_command(self, command: str, payload: str) -> None:
472476
self.logger.warning("Cannot handle MQTT command; publisher not connected.")
473477
return
474478

479+
# Mapping von MQTT-Befehl zu einer Methode (ohne Args) oder einer Lambda-Funktion (mit Args)
475480
command_mapping = {
476481
"version": self.commands.get_version,
477-
"help": self.commands.get_help,
478-
"free_ram": self.commands.get_free_ram,
482+
"freeram": self.commands.get_free_ram,
479483
"uptime": self.commands.get_uptime,
484+
# "help" wird durch "cmds" ersetzt, da der Serial Command "?" ignoriert werden sollte.
485+
"cmds": self.commands.get_cmds, # Sendet Serial Command '?' mit Regex '.*'
486+
"ping": self.commands.ping,
487+
"config": self.commands.get_config,
488+
"ccconf": self.commands.get_ccconf,
489+
"ccpatable": self.commands.get_ccpatable,
490+
"ccreg": lambda p: self.commands.read_cc1101_register(int(p, 16)),
491+
"rawmsg": lambda p: self.commands.send_raw_message(p),
480492
}
481493

494+
# Der Befehl '?' soll ignoriert werden, aber 'cmds' wurde als Ersatz eingeführt.
495+
if command == "help":
496+
self.logger.warning("Ignoring deprecated 'help' MQTT command (use 'cmds').")
497+
self.mqtt_publisher.publish_simple(f"error/{command}", "Deprecated command. Use 'cmds'.")
498+
return
499+
482500
if command in command_mapping:
483501
try:
484502
# Execute the corresponding command method
485-
response = command_mapping[command]()
503+
if command in ["ccreg", "rawmsg"]:
504+
# Befehle, die den Payload als Argument benötigen
505+
if not payload:
506+
self.logger.error("Command '%s' requires a payload argument.", command)
507+
self.mqtt_publisher.publish_simple(f"error/{command}", "Missing payload argument.")
508+
return
509+
510+
response = command_mapping[command](payload)
511+
else:
512+
# Befehle ohne Argumente
513+
response = command_mapping[command]()
486514

487515
self.logger.info("Got response for %s: %s", command, response)
488516

tests/test_controller.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def test_send_command_with_response(mock_transport, mock_parser):
8080
def write_line_side_effect(payload):
8181
# When the controller writes "V", simulate the device responding.
8282
if payload == "V":
83-
response_q.put("V 3.5.0-dev SIGNALduino\n")
83+
response_q.put("V 3.5.0-dev SIGNALduino - compiled at Mar 10 2017 22:54:50\n")
8484

8585
def readline_side_effect():
8686
# Simulate blocking read that gets a value after write_line is called.
@@ -115,7 +115,7 @@ def test_send_command_with_interleaved_message(mock_transport, mock_parser):
115115
# The irrelevant message (e.g., an asynchronous received signal)
116116
interleaved_message = "MU;P0=353;P1=-184;D=0123456789;CP=1;SP=0;R=248;\n"
117117
# The expected command response
118-
command_response = "V 3.5.0-dev SIGNALduino\n"
118+
command_response = "V 3.5.0-dev SIGNALduino - compiled at Mar 10 2017 22:54:50\n"
119119

120120
def write_line_side_effect(payload):
121121
# When the controller writes "V", simulate the device responding with
@@ -141,8 +141,9 @@ def readline_side_effect():
141141

142142
controller = SignalduinoController(transport=mock_transport, parser=mock_parser)
143143
controller.connect()
144+
time.sleep(0.2) # Give threads time to start
144145
try:
145-
response = controller.commands.get_version(timeout=1)
146+
response = controller.commands.get_version(timeout=2.0)
146147
mock_transport.write_line.assert_called_with("V")
147148

148149
# 1. Verify that the correct command response was received by send_command
@@ -156,7 +157,7 @@ def readline_side_effect():
156157
mock_parser.parse_line.assert_called_with(interleaved_message.strip())
157158

158159
# Give the parser thread a moment to process the message
159-
time.sleep(0.1)
160+
time.sleep(0.2)
160161

161162
finally:
162163
controller.disconnect()
@@ -258,4 +259,58 @@ def side_effect(*args, **kwargs):
258259
finally:
259260
signalduino.controller.SDUINO_INIT_WAIT = original_wait
260261
signalduino.controller.SDUINO_INIT_WAIT_XQ = original_wait_xq
262+
controller.disconnect()
263+
264+
def test_stx_message_bypasses_command_response(mock_transport, mock_parser):
265+
"""
266+
Test that messages starting with STX (\x02) are NOT treated as command responses,
267+
even if the command's regex (like .* for cmds) would match them.
268+
They should be passed directly to the parser.
269+
"""
270+
# Queue for responses
271+
response_q = queue.Queue()
272+
273+
# STX message (Sensor data)
274+
stx_message = "\x02SomeSensorData\x03\n"
275+
# Expected response for 'cmds' (?)
276+
cmd_response = "V X t R C S U P G r W x E Z\n"
277+
278+
def write_line_side_effect(payload):
279+
if payload == "?":
280+
# Simulate STX message followed by real response
281+
response_q.put(stx_message)
282+
response_q.put(cmd_response)
283+
284+
def readline_side_effect():
285+
try:
286+
return response_q.get(timeout=0.5)
287+
except queue.Empty:
288+
return None
289+
290+
mock_transport.write_line.side_effect = write_line_side_effect
291+
mock_transport.readline.side_effect = readline_side_effect
292+
293+
# Mock parser to verify STX message is parsed
294+
mock_parser.parse_line = Mock(wraps=mock_parser.parse_line)
295+
296+
controller = SignalduinoController(transport=mock_transport, parser=mock_parser)
297+
controller.connect()
298+
time.sleep(0.2)
299+
300+
try:
301+
# get_cmds uses pattern r".*", which would normally match the STX message
302+
# if we didn't have the special handling in the controller.
303+
response = controller.commands.get_cmds()
304+
305+
# Verify we got the correct response, not the STX message
306+
assert response is not None
307+
assert response.strip() == cmd_response.strip()
308+
309+
# Verify STX message was sent to parser
310+
mock_parser.parse_line.assert_any_call(stx_message.strip())
311+
312+
# Give parser thread some time
313+
time.sleep(0.2)
314+
315+
finally:
261316
controller.disconnect()

0 commit comments

Comments
 (0)