├── .github └── workflows │ └── dist.yml ├── .gitignore ├── LICENSE ├── README.rst ├── netconsole ├── __init__.py ├── __main__.py ├── _fakeds.py └── netconsole.py ├── pyproject.toml ├── setup.cfg ├── testing-requirements.txt └── tests ├── requirements.txt ├── run_tests.py └── test_nc.py /.github/workflows/dist.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: dist 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | tags: 10 | - '*' 11 | 12 | jobs: 13 | ci: 14 | uses: robotpy/build-actions/.github/workflows/package-pure.yml@v2022 15 | with: 16 | enable_sphinx_check: false 17 | secrets: 18 | META_REPO_ACCESS_TOKEN: ${{ secrets.REPO_ACCESS_TOKEN }} 19 | PYPI_API_TOKEN: ${{ secrets.PYPI_PASSWORD }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | __pycache__ 4 | netconsole/version.py 5 | 6 | dist 7 | 8 | .cache 9 | .project 10 | .pydevproject -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Robert Blair Mason Jr. (rbmj) rbmj@verizon.net 2 | Portions Copyright (c) 2012-2015 Dustin Spicuzza 3 | 4 | Permission to use, copy, modify, and/or distribute this software for 5 | any purpose with or without fee is hereby granted, provided that the 6 | above copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 9 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 10 | WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 11 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL 12 | DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR 13 | PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 14 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pynetconsole 2 | ============ 3 | 4 | NetConsole (also known as RIOLog) is an insecure protocol used in the FIRST 5 | Robotics Competition to view the output of Robot programs. 6 | 7 | Version 2.x only works with RoboRIOs that are imaged for 2018 or beyond. If you 8 | need to talk to a robot imaged prior to 2018, use pynetconsole 1.x instead. 9 | 10 | This implementation requires Python 3, and should work on Windows, Linux, and 11 | OSX. 12 | 13 | Installation 14 | ============ 15 | 16 | You can easily install this package via pip: 17 | 18 | pip install pynetconsole 19 | 20 | Usage 21 | ===== 22 | 23 | On Windows, you can run netconsole like so:: 24 | 25 | py -3 -m netconsole 26 | 27 | On OSX/Linux, you can run netconsole like so:: 28 | 29 | netconsole 30 | 31 | Support 32 | ======= 33 | 34 | Please file any bugs you may find on our `github issues tracker `_. 35 | 36 | Authors 37 | ======= 38 | 39 | This implementation of the netconsole listener is derived from the RIOLog 40 | code created by the GradleRIO project. 41 | -------------------------------------------------------------------------------- /netconsole/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .version import __version__ 3 | except ImportError: 4 | __version__ = "master" 5 | 6 | from .netconsole import Netconsole, run 7 | -------------------------------------------------------------------------------- /netconsole/__main__.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | from .netconsole import main 3 | 4 | main() 5 | -------------------------------------------------------------------------------- /netconsole/_fakeds.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import time 3 | import threading 4 | 5 | 6 | class FakeDS: 7 | """ 8 | Connects to the robot and convinces it that a DS is connected to it 9 | 10 | Derived from the FakeDSConnector code in GradleRIO, MIT License, Jaci R 11 | """ 12 | 13 | def start(self, address): 14 | 15 | self.running = True 16 | 17 | self.tcp_socket = socket.create_connection((address, 1740), timeout=3.0) 18 | self.tcp_socket.settimeout(5) 19 | 20 | self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 21 | self.udp_to = (address, 1110) 22 | 23 | self.udp_thread = threading.Thread(target=self._run_udp, daemon=True) 24 | self.udp_thread.start() 25 | 26 | self.tcp_thread = threading.Thread(target=self._run_tcp, daemon=True) 27 | self.tcp_thread.start() 28 | 29 | def stop(self): 30 | self.running = False 31 | self.udp_thread.join(1) 32 | self.tcp_thread.join(1) 33 | 34 | def _run_udp(self): 35 | seq = 0 36 | while self.running: 37 | seq += 1 38 | msg = bytes([seq & 0xFF, (seq >> 8) & 0xFF, 0x01, 0, 0, 0]) 39 | self.udp_socket.sendto(msg, self.udp_to) 40 | time.sleep(0.020) 41 | 42 | self.udp_socket.close() 43 | 44 | def _run_tcp(self): 45 | while self.running: 46 | self.tcp_socket.recv(1) 47 | 48 | self.tcp_socket.close() 49 | -------------------------------------------------------------------------------- /netconsole/netconsole.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | 3 | import socket 4 | import struct 5 | import sys 6 | import threading 7 | import time 8 | 9 | from ._fakeds import FakeDS 10 | 11 | __all__ = ["Netconsole", "main", "run"] 12 | 13 | 14 | def _output_fn(s): 15 | sys.stdout.write( 16 | s.encode(sys.stdout.encoding, errors="replace").decode(sys.stdout.encoding) 17 | ) 18 | sys.stdout.write("\n") 19 | 20 | 21 | class StreamEOF(IOError): 22 | pass 23 | 24 | 25 | class Netconsole: 26 | """ 27 | Implements the 2018+ netconsole protocol 28 | """ 29 | 30 | TAG_ERROR = 11 31 | TAG_INFO = 12 32 | 33 | def __init__(self, printfn=_output_fn): 34 | 35 | self.frames = {self.TAG_ERROR: self._onError, self.TAG_INFO: self._onInfo} 36 | 37 | self.cond = threading.Condition() 38 | self.sock = None 39 | self.sockrfp = None 40 | self.sockwfp = None 41 | 42 | self.sockaddr = None 43 | self.running = False 44 | 45 | self.printfn = printfn 46 | 47 | def start(self, address, port=1741, connect_event=None, block=True): 48 | with self.cond: 49 | if self.running: 50 | raise ValueError("Cannot start without stopping first") 51 | 52 | self.sockaddr = (address, port) 53 | self.connect_event = connect_event 54 | 55 | self.running = True 56 | 57 | self._rt = threading.Thread( 58 | target=self._readThread, name="nc-read-thread", daemon=True 59 | ) 60 | self._rt.start() 61 | 62 | if block: 63 | self._keepAlive() 64 | else: 65 | self._kt = threading.Thread( 66 | target=self._keepAlive, name="nc-keepalive-thread", daemon=True 67 | ) 68 | self._kt.start() 69 | 70 | @property 71 | def connected(self): 72 | return self.sockrfp is not None 73 | 74 | def stop(self): 75 | with self.cond: 76 | self.running = False 77 | self.cond.notify_all() 78 | self.sock.close() 79 | 80 | def _connectionDropped(self): 81 | print(".. connection dropped", file=sys.stderr) 82 | self.sock.close() 83 | 84 | with self.cond: 85 | self.sockrfp = None 86 | self.cond.notify_all() 87 | 88 | def _keepAliveReady(self): 89 | if not self.running: 90 | return -1 91 | elif not self.connected: 92 | return -2 93 | 94 | def _keepAlive(self): 95 | while self.running: 96 | with self.cond: 97 | ret = self.cond.wait_for(self._keepAliveReady, timeout=2.0) 98 | 99 | if ret == -1: 100 | return 101 | elif ret == -2: 102 | self._reconnect() 103 | else: 104 | try: 105 | self.sockwfp.write(b"\x00\x00") 106 | self.sockwfp.flush() 107 | except IOError: 108 | self._connectionDropped() 109 | 110 | def _readThreadReady(self): 111 | if not self.running: 112 | return -1 113 | return self.sockrfp 114 | 115 | def _readThread(self): 116 | while True: 117 | with self.cond: 118 | sockrfp = self.cond.wait_for(self._readThreadReady) 119 | if sockrfp == -1: 120 | return 121 | 122 | try: 123 | data = sockrfp.read(self._headerSz) 124 | except IOError: 125 | data = "" 126 | 127 | if len(data) != self._headerSz: 128 | self._connectionDropped() 129 | continue 130 | 131 | blen, tag = self._header.unpack(data) 132 | blen -= 1 133 | 134 | try: 135 | buf = sockrfp.read(blen) 136 | except IOError: 137 | buf = "" 138 | 139 | if len(buf) != blen: 140 | self._connectionDropped() 141 | continue 142 | 143 | # process the frame 144 | fn = self.frames.get(tag) 145 | if fn: 146 | fn(buf) 147 | else: 148 | print("ERROR: Unknown tag %s; Ignoring..." % tag, file=sys.stderr) 149 | 150 | def _reconnect(self): 151 | # returns once the socket is connected or an exit is requested 152 | 153 | while self.running: 154 | sys.stderr.write("Connecting to %s:%s..." % self.sockaddr) 155 | 156 | try: 157 | sock = socket.create_connection(self.sockaddr, timeout=3.0) 158 | except IOError: 159 | sys.stderr.write(" :(\n") 160 | # don't busywait, just in case 161 | time.sleep(1.0) 162 | continue 163 | else: 164 | sys.stderr.write("OK\n") 165 | 166 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 167 | sock.settimeout(None) 168 | 169 | sockrfp = sock.makefile("rb") 170 | sockwfp = sock.makefile("wb") 171 | 172 | if self.connect_event: 173 | self.connect_event.set() 174 | 175 | with self.cond: 176 | self.sock = sock 177 | self.sockrfp = sockrfp 178 | self.sockwfp = sockwfp 179 | self.cond.notify_all() 180 | 181 | break 182 | 183 | # 184 | # Message 185 | # 186 | 187 | _header = struct.Struct(">Hb") 188 | _headerSz = _header.size 189 | 190 | _errorFrame = struct.Struct(">fHHiB") 191 | _errorFrameSz = _errorFrame.size 192 | 193 | _infoFrame = struct.Struct(">fH") 194 | _infoFrameSz = _infoFrame.size 195 | 196 | _slen = struct.Struct(">H") 197 | _slenSz = _slen.size 198 | 199 | def _onError(self, b): 200 | ts, _seq, _numOcc, errorCode, flags = self._errorFrame.unpack_from(b, 0) 201 | details, nidx = self._getStr(b, self._errorFrameSz) 202 | location, nidx = self._getStr(b, nidx) 203 | callStack, _ = self._getStr(b, nidx) 204 | 205 | self.printfn( 206 | "[%0.2f] %d %s %s %s" % (ts, errorCode, details, location, callStack) 207 | ) 208 | 209 | def _getStr(self, b, idx): 210 | sidx = idx + self._slenSz 211 | (blen,) = self._slen.unpack_from(b, idx) 212 | nextidx = sidx + blen 213 | return b[sidx:nextidx].decode("utf-8", errors="replace"), nextidx 214 | 215 | def _onInfo(self, b): 216 | ts, _seq = self._infoFrame.unpack_from(b, 0) 217 | msg = b[self._infoFrameSz :].decode("utf-8", errors="replace") 218 | self.printfn("[%0.2f] %s" % (ts, msg)) 219 | 220 | 221 | def run(address, connect_event=None, fakeds=False): 222 | """ 223 | Starts the netconsole loop. Note that netconsole will only send output 224 | if the DS is connected. If you don't have a DS available, the 'fakeds' 225 | flag can be specified to fake a DS connection. 226 | 227 | :param address: Address of the netconsole server 228 | :param connect_event: a threading.event object, upon which the 'set' 229 | function will be called when the connection has 230 | succeeded. 231 | :param fakeds: Fake a driver station connection 232 | """ 233 | 234 | if fakeds: 235 | ds = FakeDS() 236 | ds.start(address) 237 | 238 | nc = Netconsole() 239 | nc.start(address, connect_event=connect_event) 240 | 241 | 242 | def main(): 243 | 244 | parser = ArgumentParser() 245 | parser.add_argument("address", help="Address of Robot") 246 | parser.add_argument( 247 | "-f", 248 | "--fakeds", 249 | action="store_true", 250 | default=False, 251 | help="Fake a driver station connection to the robot", 252 | ) 253 | 254 | args = parser.parse_args() 255 | 256 | run(args.address, fakeds=args.fakeds) 257 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pynetconsole 3 | description = A pure python implementation of a NetConsole listener 4 | long_description = file: README.rst 5 | long_description_content_type = text/x-rst 6 | url = https://github.com/robotpy/pynetconsole 7 | license = ISC 8 | license_file = LICENSE 9 | author = Dustin Spicuzza 10 | author_email = robotpy@googlegroups.com 11 | 12 | [options] 13 | packages = find: 14 | setup_requires = 15 | setuptools_scm > 6 16 | python_requires = >=3.6 17 | 18 | [options.entry_points] 19 | console_scripts = 20 | netconsole = netconsole.netconsole:main 21 | -------------------------------------------------------------------------------- /testing-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest -------------------------------------------------------------------------------- /tests/run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from os.path import abspath, dirname 5 | import sys 6 | import subprocess 7 | 8 | if __name__ == "__main__": 9 | 10 | root = abspath(dirname(__file__)) 11 | os.chdir(root) 12 | 13 | subprocess.check_call([sys.executable, "-m", "pytest", "-vv"]) 14 | -------------------------------------------------------------------------------- /tests/test_nc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import queue 3 | import threading 4 | 5 | from socketserver import StreamRequestHandler, TCPServer 6 | 7 | from netconsole import Netconsole 8 | 9 | 10 | class NetconsoleServerHandler(StreamRequestHandler): 11 | def handle(self): 12 | q = self.server.msg_queue 13 | 14 | while True: 15 | item = q.get(timeout=2.0) 16 | if item is None: 17 | self.server.done_queue.put(None) 18 | return 19 | 20 | self.wfile.write(item) 21 | self.wfile.flush() 22 | 23 | 24 | class NetconsoleServer(TCPServer): 25 | def __init__(self, *args, **kwargs): 26 | super().__init__(*args, **kwargs) 27 | 28 | self.msg_queue = queue.Queue() 29 | self.done_queue = queue.Queue() 30 | 31 | self.seq = 0 32 | 33 | # Python < 3.6 compatibility 34 | if not hasattr(TCPServer, "__enter__"): 35 | 36 | def __enter__(self): 37 | return self 38 | 39 | def __exit__(self, *args): 40 | self.server_close() 41 | 42 | def start(self): 43 | th = threading.Thread(target=self.serve_forever) 44 | th.start() 45 | 46 | # 47 | # Write routines: not particularly efficient, but they don't need to be 48 | # 49 | 50 | def _pack_frame(self, tag, buf): 51 | blen = len(buf) + 1 52 | msg = Netconsole._header.pack(blen, tag) + buf 53 | self.msg_queue.put(msg) 54 | 55 | def _pack_str(self, s): 56 | if not isinstance(s, bytes): 57 | s = bytes(s, "utf-8") 58 | return Netconsole._slen.pack(len(s)) + s 59 | 60 | def write_err(self, ts, numOcc, errCode, flags, details, location, callStack): 61 | seq = self.seq 62 | self.seq += 1 63 | 64 | buf = Netconsole._errorFrame.pack(ts, seq, numOcc, errCode, flags) 65 | buf += self._pack_str(details) 66 | buf += self._pack_str(location) 67 | buf += self._pack_str(callStack) 68 | self._pack_frame(Netconsole.TAG_ERROR, buf) 69 | 70 | def write_info(self, ts, msg): 71 | if not isinstance(msg, bytes): 72 | msg = bytes(msg, "utf-8") 73 | 74 | seq = self.seq 75 | self.seq += 1 76 | buf = Netconsole._infoFrame.pack(ts, seq) + msg 77 | self._pack_frame(Netconsole.TAG_INFO, buf) 78 | 79 | def write_done(self): 80 | self.msg_queue.put(None) 81 | 82 | 83 | @pytest.fixture 84 | def ncserver(): 85 | with NetconsoleServer(("127.0.0.1", 0), NetconsoleServerHandler) as server: 86 | server.start() 87 | yield server 88 | server.shutdown() 89 | 90 | 91 | def test_nc_basic(ncserver): 92 | 93 | host, port = ncserver.server_address 94 | q = queue.Queue() 95 | 96 | # start the client 97 | nc = Netconsole(printfn=q.put) 98 | nc.start(host, port, block=False) 99 | 100 | # send a normal message 101 | ncserver.write_info(1, "normal message") 102 | 103 | # send an error message 104 | ncserver.write_err(2, 1, 2, 0, "details", "location", "callstack") 105 | 106 | # send a normal message 107 | ncserver.write_info(3, "normal message") 108 | 109 | ncserver.write_done() 110 | 111 | # wait for 2 seconds for it to be sent 112 | ncserver.done_queue.get(timeout=2.0) 113 | 114 | # Retrieve 3 messages 115 | assert q.get() == "[1.00] normal message" 116 | assert q.get() == "[2.00] 2 details location callstack" 117 | assert q.get() == "[3.00] normal message" 118 | 119 | # Test reconnect 120 | ncserver.shutdown() 121 | ncserver.start() 122 | 123 | ncserver.write_info(4, "another message") 124 | ncserver.write_done() 125 | 126 | # wait for 2 seconds for it to be sent 127 | ncserver.done_queue.get(timeout=2.0) 128 | 129 | # Retrieve 1 message 130 | assert q.get() == "[4.00] another message" 131 | 132 | # shut it down 133 | nc.stop() 134 | --------------------------------------------------------------------------------