Skip to content

Commit aa76710

Browse files
committed
Merge branch 'master' of github.com:OpenCyphal/pycyphal into dev
2 parents 575d2cc + a83830a commit aa76710

File tree

9 files changed

+154
-39
lines changed

9 files changed

+154
-39
lines changed

.test_deps/README.md

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,6 @@ Please keep this document in sync with the contents of this directory.
88

99
## Nmap project binaries
1010

11-
### Portable Ncat
12-
13-
Ncat is needed for brokering TCP connections that emulate serial port connections.
14-
This is needed for testing the Cyphal/serial transport without having to access a physical serial port
15-
(which would be difficult to set up on a CI server).
16-
17-
The binary comes with the following statement by its developers:
18-
19-
> This is a portable (statically compiled) Win32 version of Ncat.
20-
> You should be able to take the ncat.exe and run it on other systems without having to also copy over
21-
> a bunch of DLLs, etc.
22-
>
23-
> More information on Ncat: http://nmap.org/ncat/
24-
>
25-
> You can get the version number of this ncat by runnign "ncat --version".
26-
> We don't create a new Ncat portable for each Ncat release,
27-
> so you will have to compile your own if you want a newer version.
28-
> Instructions for doing so are available at: https://secwiki.org/w/Nmap/Ncat_Portable
29-
>
30-
> Ncat is distributed under the same free and open source license as Nmap.
31-
> See http://nmap.org/book/man-legal.html.
32-
3311

3412
### Npcap installer
3513

CONTRIBUTING.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ a list of all the paths where the DSDL root namespace directories are to be foun
171171
Next, open 2 terminal windows.
172172
In the first, run::
173173

174-
ncat --broker --listen -p 50905
174+
cyphal-serial-broker -p 50905
175175

176176
In the second one::
177177

demo/launch.orc.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ PYCYPHAL_PATH: ".pycyphal_generated" # This one is optional; the default is "~/
1010
uavcan:
1111
# Use Cyphal/UDP via localhost:
1212
udp.iface: 127.0.0.1
13-
# If you have Ncat or some other TCP broker, you can use Cyphal/serial tunneled over TCP (in a heterogeneous
14-
# redundant configuration with UDP or standalone). Ncat launch example: ncat --broker --listen --source-port 50905
13+
# You can use Cyphal/serial tunneled over TCP (in a heterogeneous redundant configuration with
14+
# UDP or standalone). pycyphal includes cyphal-serial-broker for this purpose:
15+
# cyphal-serial-broker --port 50905
1516
serial.iface: "" # socket://127.0.0.1:50905
1617
# It is recommended to explicitly assign unused transports to ensure that previously stored transport
1718
# configurations are not accidentally reused:

noxfile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ def test(session):
7575
session.run("sudo", "setcap", "cap_net_raw+eip", str(Path(session.bin, "python").resolve()), external=True)
7676

7777
# Launch the TCP broker for testing the Cyphal/serial transport.
78-
os.system("ncat --version")
79-
broker_process = subprocess.Popen(["ncat", "--broker", "--listen", "-p", "50905"])
78+
broker_path = shutil.which("cyphal-serial-broker", path=os.pathsep.join(session.bin_paths))
79+
broker_process = subprocess.Popen([broker_path, "--port", "50905"])
8080
time.sleep(1.0) # Ensure that it has started.
8181
if broker_process.poll() is not None:
8282
raise RuntimeError("Could not start the TCP broker")

pycyphal/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.23.0"
1+
__version__ = "1.23.2"

pycyphal/transport/serial/__init__.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,23 +82,18 @@
8282
instead of a real serial port name when establishing the connection.
8383
8484
The location specified in the URL must point to the TCP server port that will forward the data
85-
to and from the other end of the link.
86-
While such a server can be trivially coded manually by the developer,
87-
it is possible to avoid the effort by relying on the TCP connection brokering mode available in
88-
Ncat (which is a part of the `Nmap <https://nmap.org>`_ project, thanks Fyodor).
85+
to and from the other end of the link. For this purpose PyCyphal includes ``cyphal-serial-broker``.
86+
Alternatively, ncat (which is a part of the `Nmap <https://nmap.org>`_ project, thanks Fyodor)
87+
has the broker mode.
8988
90-
For example, one could set up the TCP broker as follows
91-
(add ``-v`` to see what's happening; more info at https://nmap.org/ncat/guide/ncat-broker.html)
92-
(the port number is chosen at random here)::
89+
For example, one could use ``cyphal-serial-broker`` as follows (the port number is chosen at random here)::
9390
94-
ncat --broker --listen -p 50905
91+
cyphal-serial-broker -p 50906
9592
9693
And then use a serial transport with ``socket://127.0.0.1:50905``
9794
(N.B.: using ``localhost`` may significantly increase initialization latency on Windows due to slow DNS lookup).
9895
All nodes whose transports are configured like that will be able to communicate with each other,
9996
as if they were connected to the same bus.
100-
Essentially, this can be seen as a virtualized RS-485 bus,
101-
where same concerns regarding medium access coordination apply.
10297
10398
The location of the URI doesn't have to be local, of course --
10499
one can use this approach to link Cyphal nodes via conventional IP networks.

pycyphal/transport/serial/_serial.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ def _handle_received_out_of_band_data(self, timestamp: Timestamp, data: memoryvi
339339
printable = printable.decode("utf8")
340340
except ValueError:
341341
pass
342-
_logger.warning("%s: Out-of-band received at %s: %r", self._serial_port.name, timestamp, printable)
342+
_logger.info("%s: Out-of-band received at %s: %r", self._serial_port.name, timestamp, printable)
343343

344344
def _handle_received_item_and_update_stats(
345345
self, timestamp: Timestamp, item: typing.Union[SerialFrame, memoryview], in_bytes_count: int

pycyphal/util/_broker.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""
2+
Cyphal/Serial-over-TCP broker.
3+
4+
Cyphal/Serial uses COBS-encoded frames with a zero byte as frame delimiter. When
5+
brokering a byte-stream ncat --broker does know about the frame delimiter and
6+
might interleave frames from different clients.
7+
This broker is similar in functionality to :code:`ncat --broker`, but reads the
8+
whole frame before passing it on to other clients, avoiding interleaved frames
9+
and potential frame/data loss.
10+
"""
11+
12+
import argparse
13+
import asyncio
14+
import logging
15+
import socket
16+
import typing as t
17+
18+
19+
class Client:
20+
"""
21+
Represents a client connected to the broker, wrapping StreamReader and
22+
StreamWriter to conveniently read/write zero-terminated frames.
23+
"""
24+
25+
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
26+
self._buffer = bytearray()
27+
self._reader = reader
28+
self._writer = writer
29+
30+
async def __aenter__(self) -> "Client":
31+
return self
32+
33+
async def __aexit__(self, *_: t.Any) -> bool:
34+
self._writer.close()
35+
await self._writer.wait_closed()
36+
return True
37+
38+
async def read(self) -> t.AsyncGenerator[bytes, None]:
39+
"""
40+
async generator yielding complete frames, including terminating \x00.
41+
"""
42+
buffer = bytearray()
43+
while not self._reader.at_eof():
44+
buffer += await self._reader.readuntil(separator=b"\x00")
45+
# don't pass on a leading zero-byte on its own.
46+
if len(buffer) == 1:
47+
continue
48+
yield buffer
49+
buffer = bytearray()
50+
51+
def write(self, frame: bytes) -> None:
52+
"""
53+
Writes a frame to the stream, unless the stream is closing.
54+
55+
:param frame: Frame to send to this client.
56+
"""
57+
if self._writer.is_closing():
58+
return
59+
self._writer.write(frame)
60+
61+
async def drain(self) -> None:
62+
"""
63+
Flushes the stream.
64+
"""
65+
if self._writer.is_closing():
66+
return
67+
await self._writer.drain()
68+
69+
70+
async def serve_forever(host: str, port: int) -> None:
71+
"""
72+
pybroker core server loop.
73+
74+
Accept clients on :code:`host`::code:`port` and broadcast any frame
75+
received from any client to all other clients.
76+
77+
:param host: IP, where the broker will be reachable on.
78+
:param port: port, on which the broker will listen on.
79+
"""
80+
clients: list[Client] = []
81+
list_lock = asyncio.Lock()
82+
83+
async def _run_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
84+
async with Client(reader, writer) as client:
85+
async with list_lock:
86+
logging.info("Client connected.")
87+
clients.append(client)
88+
try:
89+
async for frame in client.read():
90+
logging.debug("Received frame %s", frame)
91+
for c in clients:
92+
if c != client:
93+
c.write(frame)
94+
async with list_lock:
95+
# not sure if flushing is required.
96+
for c in clients:
97+
await c.drain()
98+
99+
finally:
100+
async with list_lock:
101+
clients.remove(client)
102+
logging.info("Client disconnected.")
103+
104+
logging.info("Broker started on %s:%s", host, port)
105+
reuse_port = hasattr(socket, "SO_REUSEPORT") and socket.SO_REUSEPORT
106+
await asyncio.start_server(
107+
_run_client,
108+
host,
109+
port,
110+
family=socket.AF_INET,
111+
reuse_address=True,
112+
reuse_port=reuse_port,
113+
)
114+
115+
116+
def main() -> None:
117+
"""
118+
TCP-broker which forwards complete, zero-terminated frames/datagrams among
119+
all connected clients.
120+
"""
121+
122+
parser = argparse.ArgumentParser()
123+
parser.add_argument("-i", "--host", default="127.0.0.1", help="Interface to listen on for incoming connections.")
124+
parser.add_argument("-p", "--port", default=50905, help="Clients connect to this port.")
125+
parser.add_argument("--verbose", default=False, action="store_true", help="Increase logging verbosity.")
126+
127+
args = parser.parse_args()
128+
129+
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
130+
131+
try:
132+
loop = asyncio.get_running_loop()
133+
except RuntimeError:
134+
loop = asyncio.new_event_loop()
135+
asyncio.set_event_loop(loop)
136+
loop.run_until_complete(serve_forever(args.host, args.port))
137+
loop.run_forever()

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ classifiers =
3636
Operating System :: OS Independent
3737
Typing :: Typed
3838

39+
[options.entry_points]
40+
console_scripts =
41+
cyphal-serial-broker = pycyphal.util._broker:main
42+
3943
[options.extras_require]
4044
# Key name format: "transport-<transport-name>-<media-name>"; e.g.: "transport-ieee802154-xbee".
4145
# If there is no media sub-layer, or the media dependencies are shared, or it is desired to have a common

0 commit comments

Comments
 (0)