Skip to content

Commit c66a54c

Browse files
wait for responses after sending commands
1 parent 2772b08 commit c66a54c

File tree

6 files changed

+135
-56
lines changed

6 files changed

+135
-56
lines changed

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ sidecar-starter-pack/
159159

160160
| class `BasicCommands()` | Description |
161161
| :--- | :--- |
162-
| `wait()` | Pauses FarmBot execution for a certain number of milliseconds. Note: You may need to combine this with a `sleep()` in your sidecar code. |
162+
| `wait()` | Pauses FarmBot execution for a certain number of milliseconds. |
163163
| `e_stop()` | Emergency locks (E-stops) the Farmduino microcontroller and resets peripheral pins to OFF. |
164164
| `unlock()` | Unlocks a locked (E-stopped) device. |
165165
| `reboot()` | Reboots FarmBot OS and reinitializes the device. |
@@ -172,8 +172,6 @@ sidecar-starter-pack/
172172
| `connect()` | Establish a persistent connection to send messages via the message broker. |
173173
| `disconnect()` | Disconnect from the message broker. |
174174
| `publish()` | Publish messages containing CeleryScript via the message broker. |
175-
| `on_connect()` | Callback function triggered when a connection to the message broker is successfully established. |
176-
| `on_message()` | Callback function triggered when a message is received from the message broker. |
177175
| `start_listen()` | Establish persistent subscription to message broker channels. |
178176
| `stop_listen()` | End subscription to all message broker channels. |
179177
| `listen()` | Listen to a message broker channel for the provided duration in seconds. |

farmbot_sidecar_starter_pack/functions/broker.py

Lines changed: 78 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66
# ├── [BROKER] connect()
77
# ├── [BROKER] disconnect()
88
# ├── [BROKER] publish()
9-
# ├── [BROKER] on_connect()
10-
# ├── [BROKER] on_message
119
# ├── [BROKER] start_listen()
1210
# ├── [BROKER] stop_listen()
1311
# └── [BROKER] listen()
1412

1513
import time
1614
import json
15+
import uuid
1716
from datetime import datetime
1817
import paho.mqtt.client as mqtt
1918

@@ -30,12 +29,12 @@ def connect(self):
3029

3130
self.client = mqtt.Client()
3231
self.client.username_pw_set(
33-
username=self.state.token['token']['unencoded']['bot'],
34-
password=self.state.token['token']['encoded']
32+
username=self.state.token["token"]["unencoded"]["bot"],
33+
password=self.state.token["token"]["encoded"]
3534
)
3635

3736
self.client.connect(
38-
self.state.token['token']['unencoded']['mqtt'],
37+
self.state.token["token"]["unencoded"]["mqtt"],
3938
port=1883,
4039
keepalive=60
4140
)
@@ -63,7 +62,7 @@ def wrap_message(self, message, priority=None):
6362
}
6463

6564
if priority is not None:
66-
rpc['args']['priority'] = priority
65+
rpc["args"]["priority"] = priority
6766

6867
return rpc
6968

@@ -73,80 +72,118 @@ def publish(self, message):
7372
if self.client is None:
7473
self.connect()
7574

76-
if message["kind"] != "rpc_request":
77-
message = self.wrap_message(message)
75+
rpc = message
76+
if rpc["kind"] != "rpc_request":
77+
rpc = self.wrap_message(rpc)
7878

79-
device_id_str = self.state.token["token"]["unencoded"]["bot"]
80-
topic = f"bot/{device_id_str}/from_clients"
81-
if not self.state.dry_run:
82-
self.client.publish(topic, payload=json.dumps(message))
83-
self.state.print_status(description=f"Publishing to {topic}:")
84-
self.state.print_status(endpoint_json=message, update_only=True)
79+
if rpc["args"]["label"] == "":
80+
rpc["args"]["label"] = uuid.uuid4().hex if not self.state.test_env else "test"
81+
82+
self.state.print_status(description="Publishing to 'from_clients':")
83+
self.state.print_status(endpoint_json=rpc, update_only=True)
8584
if self.state.dry_run:
8685
self.state.print_status(description="Sending disabled, message not sent.", update_only=True)
86+
else:
87+
self.listen("from_device", publish_payload=rpc)
8788

88-
def on_connect(self, _client, _userdata, _flags, _rc, channel):
89-
"""Callback function when connection to message broker is successful."""
90-
91-
self.client.subscribe(
92-
f"bot/{self.state.token['token']['unencoded']['bot']}/{channel}")
93-
94-
self.state.print_status(description=f"Connected to message broker channel {channel}")
95-
96-
def on_message(self, _client, _userdata, msg, channel):
97-
"""Callback function when message received from message broker."""
98-
99-
self.state.last_messages[channel] = json.loads(msg.payload)
89+
response = self.state.last_messages.get("from_device")
90+
if response is not None:
91+
if response["kind"] == "rpc_ok":
92+
self.state.print_status(description="Success response received.", update_only=True)
93+
self.state.error = None
94+
else:
95+
self.state.print_status(description="Error response received.", update_only=True)
96+
self.state.error = "RPC error response received."
10097

101-
self.state.print_status(endpoint_json=json.loads(msg.payload), description=f"TOPIC: {msg.topic} ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})\n")
98+
self.state.last_published = rpc
10299

103100
def start_listen(self, channel="#"):
104101
"""Establish persistent subscription to message broker channels."""
105102

106103
if self.client is None:
107104
self.connect()
108105

109-
def on_connect(client, userdata, flags, rc):
110-
"""Wrap on_connect to pass channel argument."""
111-
self.on_connect(client, userdata, flags, rc, channel)
106+
# Set on_message callback
107+
def on_message(_client, _userdata, msg):
108+
"""on_message callback"""
112109

113-
def on_message(client, userdata, msg):
114-
"""Wrap on_message to pass channel argument."""
115-
self.on_message(client, userdata, msg, channel)
110+
self.state.last_messages[channel] = json.loads(msg.payload)
111+
112+
113+
self.state.print_status(description="", update_only=True)
114+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
115+
self.state.print_status(
116+
endpoint_json=json.loads(msg.payload),
117+
description=f"TOPIC: {msg.topic} ({timestamp})\n")
116118

117-
self.client.on_connect = on_connect
118119
self.client.on_message = on_message
119120

121+
# Subscribe to channel
122+
device_id_str = self.state.token["token"]["unencoded"]["bot"]
123+
self.client.subscribe(f"bot/{device_id_str}/{channel}")
124+
self.state.print_status(description=f"Connected to message broker channel '{channel}'")
125+
126+
# Start listening
120127
self.client.loop_start()
121-
self.state.print_status(description=f"Now listening to message broker channel {channel}.")
128+
self.state.print_status(description=f"Now listening to message broker channel '{channel}'.")
122129

123130
def stop_listen(self):
124131
"""End subscription to all message broker channels."""
125132

126133
self.client.loop_stop()
127-
self.client.disconnect()
128134

129135
self.state.print_status(description="Stopped listening to all message broker channels.")
130136

131-
def listen(self, duration, channel):
137+
def listen(self, channel, duration=None, publish_payload=None):
132138
"""Listen to a message broker channel for the provided duration in seconds."""
133-
self.state.print_status(description=f"Listening to message broker for {duration} seconds...")
139+
# Prepare parameters
140+
duration_seconds = duration or self.state.broker_listen_duration
141+
message = (publish_payload or {}).get("body", [{}])[0]
142+
if message.get("kind") == "wait":
143+
duration_seconds += message["args"]["milliseconds"] / 1000
144+
publish = publish_payload is not None
145+
label = None
146+
if publish and publish_payload["args"]["label"] != "":
147+
label = publish_payload["args"]["label"]
148+
149+
# Print status message
150+
channel_str = f" channel '{channel}'" if channel != "#" else ""
151+
duration_str = f" for {duration_seconds} seconds"
152+
label_str = f" for label '{label}'" if label is not None else ""
153+
description = f"Listening to message broker{channel_str}{duration_str}{label_str}..."
154+
self.state.print_status(description=description)
155+
156+
# Start listening
134157
start_time = datetime.now()
135158
self.start_listen(channel)
136159
if not self.state.test_env:
137160
self.state.last_messages[channel] = None
138-
while (datetime.now() - start_time).seconds < duration:
161+
if publish:
162+
time.sleep(0.1) # wait for start_listen to be ready
163+
device_id_str = self.state.token["token"]["unencoded"]["bot"]
164+
publish_topic = f"bot/{device_id_str}/from_clients"
165+
self.client.publish(publish_topic, payload=json.dumps(publish_payload))
166+
self.state.print_status(update_only=True, description="", end="")
167+
while (datetime.now() - start_time).seconds < duration_seconds:
139168
self.state.print_status(update_only=True, description=".", end="")
140169
time.sleep(0.25)
141-
if self.state.last_messages.get(channel) is not None:
170+
last_message = self.state.last_messages.get(channel)
171+
if last_message is not None:
172+
# If a label is provided, verify the label matches
173+
if label is not None and last_message["args"]["label"] != label:
174+
self.state.last_messages[channel] = None
175+
continue
142176
seconds = (datetime.now() - start_time).seconds
177+
self.state.print_status(description="", update_only=True)
143178
self.state.print_status(
144179
description=f"Message received after {seconds} seconds",
145180
update_only=True)
146181
break
147182
if self.state.last_messages.get(channel) is None:
183+
self.state.print_status(description="", update_only=True)
148184
self.state.print_status(
149-
description=f"Did not receive message after {duration} seconds",
185+
description=f"Did not receive message after {duration_seconds} seconds",
150186
update_only=True)
187+
self.state.error = "Timed out waiting for RPC response."
151188

152189
self.stop_listen()

farmbot_sidecar_starter_pack/functions/information.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def read_status(self):
148148
}
149149
self.broker.publish(status_message)
150150

151-
self.broker.listen(self.state.broker_listen_duration, "status")
151+
self.broker.listen("status")
152152

153153
status_tree = self.state.last_messages.get("status")
154154

farmbot_sidecar_starter_pack/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .functions.resources import Resources
1616
from .functions.tools import ToolControls
1717

18-
VERSION = "1.1.1"
18+
VERSION = "1.2.0"
1919

2020
class Farmbot():
2121
"""Farmbot class."""
@@ -83,9 +83,9 @@ def disconnect_broker(self):
8383
"""Disconnect from the message broker."""
8484
return self.broker.disconnect()
8585

86-
def listen(self, duration, channel="#"):
86+
def listen(self, channel="#", duration=None):
8787
"""Listen to a message broker channel for the provided duration in seconds."""
88-
return self.broker.listen(duration, channel)
88+
return self.broker.listen(channel, duration)
8989

9090
# camera.py
9191

farmbot_sidecar_starter_pack/state.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def __init__(self):
3333
self.token = None
3434
self.error = None
3535
self.last_messages = {}
36+
self.last_published = {}
3637
self.verbosity = 2
3738
self.broker_listen_duration = 15
3839
self.test_env = False
@@ -46,7 +47,8 @@ def print_status(self, endpoint_json=None, description=None, update_only=False,
4647
if depth < self.min_call_stack_depth:
4748
self.min_call_stack_depth = depth
4849
top = depth == self.min_call_stack_depth
49-
indent = "" if top else " " * 4
50+
no_end = end == "" and description != ""
51+
indent = "" if (top or no_end) else " " * 4
5052

5153
if self.verbosity >= 2 and not update_only:
5254
if top:
@@ -56,7 +58,7 @@ def print_status(self, endpoint_json=None, description=None, update_only=False,
5658
if self.verbosity >= 1:
5759
if self.verbosity == 1 and not update_only and top:
5860
print()
59-
if description:
61+
if description is not None:
6062
print(indent + description, end=end, flush=(end == ""))
6163
if endpoint_json:
6264
json_str = json.dumps(endpoint_json, indent=4)

tests/tests_main.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def setUp(self):
3030
self.fb.set_token(MOCK_TOKEN)
3131
self.fb.set_verbosity(0)
3232
self.fb.state.test_env = True
33-
self.fb.state.broker_listen_duration = 0.3
33+
self.fb.state.broker_listen_duration = 0
3434

3535
@patch('requests.post')
3636
def test_get_token_default_server(self, mock_post):
@@ -577,8 +577,7 @@ def test_listen(self, mock_mqtt):
577577
'''Test listen command'''
578578
mock_client = Mock()
579579
mock_mqtt.return_value = mock_client
580-
self.fb.listen(1)
581-
mock_client.on_connect('', '', '', '')
580+
self.fb.listen()
582581

583582
class MockMessage:
584583
'''Mock message class'''
@@ -595,7 +594,6 @@ class MockMessage:
595594
mock_client.subscribe.assert_called_once_with('bot/device_0/#')
596595
mock_client.loop_start.assert_called()
597596
mock_client.loop_stop.assert_called()
598-
mock_client.disconnect.assert_called()
599597

600598
@patch('paho.mqtt.client.Client')
601599
def test_listen_clear_last(self, mock_mqtt):
@@ -604,9 +602,18 @@ def test_listen_clear_last(self, mock_mqtt):
604602
mock_mqtt.return_value = mock_client
605603
self.fb.state.last_messages = {'#': "message"}
606604
self.fb.state.test_env = False
607-
self.fb.listen(1)
605+
self.fb.listen()
608606
self.assertIsNone(self.fb.state.last_messages['#'])
609607

608+
@patch('paho.mqtt.client.Client')
609+
def test_publish_apply_label(self, mock_mqtt):
610+
'''Test publish command: set uuid'''
611+
mock_client = Mock()
612+
mock_mqtt.return_value = mock_client
613+
self.fb.state.test_env = False
614+
self.fb.broker.publish({'kind': 'sync', 'args': {}})
615+
self.assertNotIn(self.fb.state.last_published.get('args', {}).get('label'), ['test', '', None])
616+
610617
@patch('requests.request')
611618
@patch('paho.mqtt.client.Client')
612619
def send_command_test_helper(self, *args, **kwargs):
@@ -617,20 +624,25 @@ def send_command_test_helper(self, *args, **kwargs):
617624
expected_command = kwargs.get('expected_command')
618625
extra_rpc_args = kwargs.get('extra_rpc_args')
619626
mock_api_response = kwargs.get('mock_api_response')
627+
error = kwargs.get('error')
620628
mock_client = Mock()
621629
mock_mqtt.return_value = mock_client
622630
mock_response = Mock()
623631
mock_response.json.return_value = mock_api_response
624632
mock_response.status_code = 200
625633
mock_response.text = 'text'
626634
mock_request.return_value = mock_response
635+
self.fb.state.last_messages['from_device'] = {
636+
'kind': 'rpc_error' if error else 'rpc_ok',
637+
'args': {'label': 'test'},
638+
}
627639
execute_command()
628640
if expected_command is None:
629641
mock_client.publish.assert_not_called()
630642
return
631643
expected_payload = {
632644
'kind': 'rpc_request',
633-
'args': {'label': '', **extra_rpc_args},
645+
'args': {'label': 'test', **extra_rpc_args},
634646
'body': [expected_command],
635647
}
636648
mock_client.username_pw_set.assert_called_once_with(
@@ -644,6 +656,8 @@ def send_command_test_helper(self, *args, **kwargs):
644656
mock_client.publish.assert_called_once_with(
645657
'bot/device_0/from_clients',
646658
payload=json.dumps(expected_payload))
659+
if not error:
660+
self.assertNotEqual(self.fb.state.error, 'RPC error response received.')
647661

648662
def test_message(self):
649663
'''Test message command'''
@@ -1752,6 +1766,34 @@ def exec_command():
17521766
extra_rpc_args={},
17531767
mock_api_response=[])
17541768

1769+
def test_rpc_error(self):
1770+
'''Test rpc error handling'''
1771+
def exec_command():
1772+
self.fb.wait(100)
1773+
self.assertEqual(self.fb.state.error, 'RPC error response received.')
1774+
self.send_command_test_helper(
1775+
exec_command,
1776+
error=True,
1777+
expected_command={
1778+
'kind': 'wait',
1779+
'args': {'milliseconds': 100}},
1780+
extra_rpc_args={},
1781+
mock_api_response=[])
1782+
1783+
def test_rpc_response_timeout(self):
1784+
'''Test rpc response timeout handling'''
1785+
def exec_command():
1786+
self.fb.state.last_messages['from_device'] = {'kind': 'rpc_ok', 'args': {'label': 'wrong label'}}
1787+
self.fb.wait(100)
1788+
self.assertEqual(self.fb.state.error, 'Timed out waiting for RPC response.')
1789+
self.send_command_test_helper(
1790+
exec_command,
1791+
expected_command={
1792+
'kind': 'wait',
1793+
'args': {'milliseconds': 100}},
1794+
extra_rpc_args={},
1795+
mock_api_response=[])
1796+
17551797
@staticmethod
17561798
def helper_get_print_strings(mock_print):
17571799
'''Test helper to get print call strings.'''

0 commit comments

Comments
 (0)