-
Notifications
You must be signed in to change notification settings - Fork 1
Rework robotd JSON connections #78
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,25 +1,23 @@ | ||
| import json | ||
| import logging | ||
| import socket | ||
| import time | ||
| from pathlib import Path | ||
| from typing import Mapping, TypeVar, Union | ||
|
|
||
| from .connection import Connection, Message | ||
|
|
||
| LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class Board: | ||
| """Base class for connections to ``robotd`` board sockets.""" | ||
|
|
||
| SEND_TIMEOUT_SECS = 6 | ||
| RECV_BUFFER_BYTES = 2048 | ||
| CONNECTION_TIMEOUT_SECS = 6 | ||
|
|
||
| def __init__(self, socket_path: Union[Path, str]) -> None: | ||
| self.socket_path = Path(socket_path) | ||
| self.socket = None | ||
| self.data = b'' | ||
|
|
||
| self._connect() | ||
| self.connection = Connection(self._get_socket()) | ||
| self._greeting_response(self.connection.receive()) | ||
|
|
||
| @property | ||
| def serial(self): | ||
|
|
@@ -34,23 +32,28 @@ def _greeting_response(self, data): | |
| """ | ||
| pass | ||
|
|
||
| def _connect(self): | ||
| def _get_socket(self): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we keep the word "connect" in the name of this function? To me, "get socket" sounds like the function returns some already-existing socket rather than creating and connecting a new one.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I was going to have it not connect at one point (just prime the socket), you're right that it's not great as it stands.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think something like |
||
| """ | ||
| Connect or reconnect to a socket. | ||
|
|
||
| :param socket_path: Path for the unix socket | ||
| """ | ||
| self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||
| self.socket.settimeout(self.SEND_TIMEOUT_SECS) | ||
| sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||
| sock.settimeout(self.CONNECTION_TIMEOUT_SECS) | ||
|
|
||
| try: | ||
| self.socket.connect(str(self.socket_path)) | ||
| sock.connect(str(self.socket_path)) | ||
| except ConnectionRefusedError as e: | ||
| LOGGER.exception("Error connecting to: '%s'", self.socket_path) | ||
| raise | ||
|
|
||
| greeting = self._receive() | ||
| self._greeting_response(greeting) | ||
| return sock | ||
|
|
||
| def _reconnect(self) -> None: | ||
| self.connection.close() | ||
|
|
||
| self.connection = Connection(self._get_socket()) | ||
| self._greeting_response(self.connection.receive()) | ||
|
|
||
| def _get_lc_error(self) -> str: | ||
| """ | ||
|
|
@@ -63,97 +66,40 @@ def _get_lc_error(self) -> str: | |
| path=self.socket_path, | ||
| ) | ||
|
|
||
| def _socket_with_single_retry(self, handler): | ||
| retryable_errors = ( | ||
| socket.timeout, | ||
| BrokenPipeError, | ||
| OSError, | ||
| ConnectionResetError, | ||
| ) | ||
|
|
||
| backoffs = [ | ||
| 0.1, | ||
| 0.5, | ||
| 1.0, | ||
| 2.0, | ||
| 3.0, | ||
| ] | ||
|
|
||
| try: | ||
| return handler() | ||
| except retryable_errors as e: | ||
| original_exception = e | ||
|
|
||
| for backoff in backoffs: | ||
| time.sleep(backoff) | ||
|
|
||
| try: | ||
| self._connect() | ||
| except (ConnectionRefusedError, FileNotFoundError): | ||
| continue | ||
|
|
||
| try: | ||
| return handler() | ||
| except retryable_errors: | ||
| pass | ||
|
|
||
| raise original_exception | ||
|
|
||
| def _send(self, message, should_retry=True): | ||
| def _send(self, message: Message) -> None: | ||
| """ | ||
| Send a message to robotd. | ||
|
|
||
| :param retry: used internally | ||
| :param message: message to send | ||
| """ | ||
| try: | ||
| self.connection.send(message) | ||
| except (BrokenPipeError, ConnectionError): | ||
| self._reconnect() | ||
| self.connection.send(message) | ||
|
|
||
| data = (json.dumps(message) + '\n').encode('utf-8') | ||
|
|
||
| def sendall(): | ||
| self.socket.sendall(data) | ||
|
|
||
| if should_retry: | ||
| return self._socket_with_single_retry(sendall) | ||
| else: | ||
| return sendall() | ||
|
|
||
| def _recv_from_socket(self, size): | ||
| data = self.socket.recv(size) | ||
| if data == b'': | ||
| raise BrokenPipeError() | ||
| return data | ||
|
|
||
| def _receive(self, should_retry=True): | ||
| def _receive(self): | ||
| """ | ||
| Receive a message from robotd. | ||
| """ | ||
| while b'\n' not in self.data: | ||
| if should_retry: | ||
| message = self._socket_with_single_retry( | ||
| lambda: self._recv_from_socket(4096), | ||
| ) | ||
| else: | ||
| message = self._recv_from_socket(4096) | ||
|
|
||
| self.data += message | ||
|
|
||
| line = self.data.split(b'\n', 1)[0] | ||
| self.data = self.data[len(line) + 1:] | ||
|
|
||
| return json.loads(line.decode('utf-8')) | ||
| try: | ||
| return self.connection.receive() | ||
| except (BrokenPipeError, ConnectionError): | ||
| self._reconnect() | ||
| return self.connection.receive() | ||
|
|
||
| def _send_and_receive(self, message, should_retry=True): | ||
| def _send_and_receive(self, message): | ||
| """ | ||
| Send a message to robotd and wait for a response. | ||
| """ | ||
| self._send(message, should_retry) | ||
| return self._receive(should_retry) | ||
| self._send(message) | ||
| return self._receive() | ||
|
|
||
| def close(self): | ||
| """ | ||
| Close the the connection to the underlying robotd board. | ||
| """ | ||
| self.socket.detach() | ||
| self.connection.close() | ||
|
|
||
| def __str__(self): | ||
| return "{} - {}".format(self.__name__, self.serial) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import json | ||
| import socket | ||
| from typing import Any, Dict | ||
|
|
||
| Message = Dict[str, Any] | ||
|
|
||
|
|
||
| class Connection: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It feels to me like "codec" would be a much better name for this class and module than "connection", especially since this class doesn't actually do any connecting (that's done in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Possibly, though it also does buffering which I don't think a codec would do. As it stands the name is merely the same as the class this is copied from in robotd/master.py. I think it would be good to keep the two classes having the same name, but I don't feel strongly about what the name actually is.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that it would be good for the two classes to have the same name; as a result, I think we should rename both of them. Let's leave that to another PR, though. |
||
| """ | ||
| A connection to a device. | ||
| This wraps a ``socket.socket`` providing encoding and decoding so that | ||
| consumers of this class can send and receive JSON-compatible typed data | ||
| rather than needing to worry about lower-level details. | ||
| """ | ||
|
|
||
| def __init__(self, socket: socket.socket) -> None: | ||
| """Wrap the given socket.""" | ||
| self.socket = socket | ||
| self.data = b'' | ||
|
|
||
| def close(self) -> None: | ||
| """Close the connection.""" | ||
| self.socket.close() | ||
|
|
||
| def send(self, message: Message) -> None: | ||
| """Send the given JSON-compatible message over the connection.""" | ||
| line = json.dumps(message).encode('utf-8') + b'\n' | ||
| self.socket.sendall(line) | ||
|
|
||
| def receive(self) -> Message: | ||
| """Receive a single message from the connection.""" | ||
| while b'\n' not in self.data: | ||
| message = self.socket.recv(4096) | ||
| if message == b'': | ||
| raise BrokenPipeError() | ||
|
|
||
| self.data += message | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be worth using an However, your current approach has the advantage of being more explicit about its error handling. It's not clear without looking at its source how
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting, I'll give that a look. I also found that we can turn a socket into a file-like object, though again I'm not sure how that handles errors. As it stands, this class is pretty much copied from robotd/master.py. If we do find that there's a good wrapper then we should consider using it there, too.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tangentially related (and definitely out of scope), but a long-term improvement could be to factor out this logic from both robotd and robot-api into a library used by both (e.g. "robot-protocol"). This would facilitate writing other applications that interact with robotd, such as a manual control interface or the fabled Power Board Orchestra.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup, a common library was another thing I'd wondered about here. I think we'd want to pin down some even higher level things before/while doing that (such as a generic way to pass errors back & forth) too. I'd hope that something like a manual control interface would sit above the robot-api though -- is there a reason you think it needs to be a sibling?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
My bad - that would make much more sense that using the hypothetical Come to think of it I'm not sure what applications, if any, would use |
||
| line = self.data.split(b'\n', 1)[0] | ||
| self.data = self.data[len(line) + 1:] | ||
|
|
||
| return json.loads(line.decode('utf-8')) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code is also duplicated in
_reconnect. Perhaps it would be neater to initialiseconnectiontoNonehere and then immediately call_reconnect, and modify_reconnectto add a check forconnection is not Nonearound the call toconnection.close?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is deliberate -- I'm precisely moving away from having
Nones floating around the place. This is partly because they're annoying and partly because adding types exposes that.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. In that case, it could be factored out into a new method that returns the new
Connection?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
e.g.
Assuming
self.connectionis not modified outside of the above functions, it will never beNone.In theory
_greeting_responsewould need to be modified Assuming the above code is the olAssuming the above code is the olto usenew_connectionrather thanself.connection, though since it's now only used once it could just be inlined into_connect. However I've just seen that our implementation of_greeting_responseis simply a no-op, so I think it'd be clearer to just delete it (since clearly robotd does not require nor accept a response to the greeting packet it sends).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_greeting_responseis, I think, provided so thatBoardimplementations can handle the initial status broadcast fromrobotd. You're right that it's unused at the moment.I'll have a look at the rest later.