├── .gitignore ├── LICENSE ├── README.rst ├── can_remote ├── __init__.py ├── __main__.py ├── client.py ├── protocol.py ├── server.py ├── version.py ├── web │ ├── .gitignore │ ├── README.md │ ├── assets │ │ ├── bundle.js.gz │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ ├── index.html │ ├── main.js │ ├── package-lock.json │ ├── package.json │ ├── style.css │ └── webpack.config.js └── websocket.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Christian Sandberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | CAN over network bridge for Python 2 | ================================== 3 | 4 | Creates a CAN over TCP/IP bridge for use with python-can_. 5 | 6 | 7 | Installation 8 | ------------ 9 | 10 | Install using pip:: 11 | 12 | $ pip install python-can-remote 13 | 14 | 15 | Usage 16 | ----- 17 | 18 | Start server from command line:: 19 | 20 | $ python -m can_remote --interface=virtual --channel=0 --bitrate=500000 21 | 22 | 23 | Create python-can bus: 24 | 25 | .. code-block:: python 26 | 27 | import can 28 | 29 | # Create a connection to server. Any config is passed to server. 30 | bus = can.Bus('ws://localhost:54701/', 31 | bustype='remote', 32 | bitrate=500000, 33 | receive_own_messages=True) 34 | 35 | # Send messages 36 | msg = can.Message(arbitration_id=0x12345, data=[1,2,3,4,5,6,7,8]) 37 | bus.send(msg) 38 | 39 | # Receive messages 40 | msg2 = bus.recv(1) 41 | print(msg2) 42 | 43 | # Disconnect 44 | bus.shutdown() 45 | 46 | 47 | Web interface 48 | ------------- 49 | 50 | There is also a basic web interface for inspecting the CAN traffic 51 | using a browser. 52 | It is available on the same address using HTTP, e.g. http://localhost:54701/. 53 | 54 | 55 | .. _python-can: https://python-can.readthedocs.org/en/stable/ 56 | -------------------------------------------------------------------------------- /can_remote/__init__.py: -------------------------------------------------------------------------------- 1 | DEFAULT_PORT = 54701 2 | 3 | 4 | from .client import RemoteBus, CyclicSendTask 5 | from .server import RemoteServer 6 | from .protocol import RemoteError 7 | from .version import __version__ 8 | -------------------------------------------------------------------------------- /can_remote/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import argparse 4 | try: 5 | import ssl 6 | except ImportError: 7 | ssl = None 8 | import can 9 | from .server import RemoteServer 10 | from . import DEFAULT_PORT 11 | 12 | logging.basicConfig(format='%(asctime)-15s %(message)s', level=logging.DEBUG) 13 | can.set_logging_level("DEBUG") 14 | 15 | 16 | def main(): 17 | parser = argparse.ArgumentParser("python -m can_server", 18 | description="Remote CAN server") 19 | 20 | parser.add_argument('-v', action='count', dest="verbosity", 21 | help='''How much information do you want to see at the command line? 22 | You can add several of these e.g., -vv is DEBUG''', default=3) 23 | 24 | parser.add_argument('-c', '--channel', help='''Most backend interfaces require some sort of channel. 25 | For example with the serial interface the channel might be a rfcomm device: "/dev/rfcomm0" 26 | With the socketcan interfaces valid channel examples include: "can0", "vcan0". 27 | The server will only serve this channel. Start additional servers at different 28 | ports to share more channels.''') 29 | 30 | parser.add_argument('-i', '--interface', 31 | help='''Specify the backend CAN interface to use. If left blank, 32 | fall back to reading from configuration files.''', 33 | choices=can.VALID_INTERFACES) 34 | 35 | parser.add_argument('-b', '--bitrate', type=int, 36 | help='''Force to use a specific bitrate. 37 | This will override any requested bitrate by the clients.''') 38 | 39 | parser.add_argument('-H', '--host', 40 | help='''Host to listen to (default 0.0.0.0).''', 41 | default='0.0.0.0') 42 | 43 | parser.add_argument('-p', '--port', type=int, 44 | help='''TCP port to listen on (default %d).''' % DEFAULT_PORT, 45 | default=DEFAULT_PORT) 46 | 47 | if ssl is not None: 48 | parser.add_argument('-C', '--cert', 49 | help='SSL certificate in PEM format') 50 | 51 | parser.add_argument('-K', '--key', 52 | help='''SSL private key in PEM format 53 | (optional if provided in cert file)''') 54 | 55 | results = parser.parse_args() 56 | 57 | verbosity = results.verbosity 58 | logging_level_name = ['critical', 'error', 'warning', 'info', 'debug', 'subdebug'][min(5, verbosity)] 59 | can.set_logging_level(logging_level_name) 60 | 61 | config = {} 62 | if results.channel: 63 | config["channel"] = results.channel 64 | if results.interface: 65 | config["bustype"] = results.interface 66 | if results.bitrate: 67 | config["bitrate"] = results.bitrate 68 | 69 | if results.cert and ssl is not None: 70 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 71 | context.load_cert_chain(certfile=results.cert, keyfile=results.key) 72 | else: 73 | context = None 74 | 75 | server = RemoteServer(results.host, results.port, 76 | ssl_context=context, **config) 77 | try: 78 | server.serve_forever() 79 | except KeyboardInterrupt: 80 | pass 81 | logging.info("Closing server") 82 | server.server_close() 83 | 84 | 85 | if __name__ == "__main__": 86 | main() 87 | -------------------------------------------------------------------------------- /can_remote/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | try: 3 | import ssl 4 | # Create SSL context which allows self-signed cerificates 5 | DEFAULT_SSL_CONTEXT = ssl.create_default_context() 6 | DEFAULT_SSL_CONTEXT.check_hostname = False 7 | DEFAULT_SSL_CONTEXT.verify_mode = ssl.CERT_NONE 8 | except ImportError: 9 | ssl = None 10 | DEFAULT_SSL_CONTEXT = None 11 | import can 12 | from .protocol import RemoteProtocolBase, RemoteError 13 | from .websocket import WebSocket, WebsocketClosed 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class RemoteBus(can.bus.BusABC): 20 | """CAN bus over a network connection bridge.""" 21 | 22 | def __init__(self, channel, ssl_context=None, **config): 23 | """ 24 | :param str channel: 25 | Address of server as ws://host:port/path. 26 | :param ssl.SSLContext ssl_context: 27 | SSL context to use for secure connections. 28 | The default will allow self-signed cerificates. 29 | """ 30 | url = channel if "://" in channel else "ws://" + channel 31 | if ssl_context is None: 32 | ssl_context = DEFAULT_SSL_CONTEXT 33 | websocket = WebSocket(url, ["can.binary+json.v1", "can.json.v1"], 34 | ssl_context=ssl_context) 35 | self.remote_protocol = RemoteClientProtocol(config, websocket) 36 | self.socket = websocket.socket 37 | self.channel_info = self.remote_protocol.channel_info 38 | self.channel = channel 39 | super().__init__(channel) 40 | 41 | def fileno(self): 42 | return self.socket.fileno() 43 | 44 | def recv(self, timeout=None): 45 | """Block waiting for a message from the Bus. 46 | 47 | :param float timeout: Seconds to wait for a message. 48 | 49 | :return: 50 | None on timeout or a Message object. 51 | :rtype: can.Message 52 | :raises can.interfaces.remote.protocol.RemoteError: 53 | """ 54 | event = self.remote_protocol.recv(timeout) 55 | if isinstance(event, can.Message): 56 | return event 57 | return None 58 | 59 | def send(self, msg, timeout=None): 60 | """Transmit a message to CAN bus. 61 | 62 | :param can.Message msg: A Message object. 63 | """ 64 | self.remote_protocol.send_msg(msg) 65 | 66 | def send_periodic(self, message, period, duration=None): 67 | """Start sending a message at a given period on the remote bus. 68 | 69 | :param can.Message msg: 70 | Message to transmit 71 | :param float period: 72 | Period in seconds between each message 73 | :param float duration: 74 | The duration to keep sending this message at given rate. If 75 | no duration is provided, the task will continue indefinitely. 76 | 77 | :return: A started task instance 78 | """ 79 | return CyclicSendTask(self, message, period, duration) 80 | 81 | def shutdown(self): 82 | """Close socket connection.""" 83 | # Give threads a chance to finish up 84 | logger.debug('Closing connection to server') 85 | self.remote_protocol.close() 86 | while True: 87 | try: 88 | self.remote_protocol.recv(1) 89 | except WebsocketClosed: 90 | break 91 | except RemoteError: 92 | pass 93 | # Shutdown on parent side for proper state 94 | # (like _is_shutdown flag must be False when shutdown is finished) 95 | super().shutdown() 96 | logger.debug('Network connection closed') 97 | 98 | 99 | class RemoteClientProtocol(RemoteProtocolBase): 100 | 101 | def __init__(self, config, websocket): 102 | super(RemoteClientProtocol, self).__init__(websocket) 103 | self.send_bus_request(config) 104 | event = self.recv(5) 105 | if event is None: 106 | raise RemoteError("No response from server") 107 | if event.get("type") != "bus_response": 108 | raise RemoteError("Invalid response from server") 109 | self.channel_info = '%s on %s' % ( 110 | event["payload"]["channel_info"], websocket.url) 111 | 112 | def send_bus_request(self, config): 113 | self.send("bus_request", {"config": config}) 114 | 115 | def send_periodic_start(self, msg: can.Message, period: float, duration: float): 116 | msg_payload = { 117 | "arbitration_id": msg.arbitration_id, 118 | "is_extended_id": msg.is_extended_id, 119 | "is_remote_frame": msg.is_remote_frame, 120 | "is_error_frame": msg.is_error_frame, 121 | "dlc": msg.dlc, 122 | "data": list(msg.data), 123 | } 124 | payload = { 125 | "period": period, 126 | "duration": duration, 127 | "msg": msg_payload 128 | } 129 | self.send("periodic_start", payload) 130 | 131 | def send_periodic_stop(self, arbitration_id): 132 | self.send("periodic_stop", arbitration_id) 133 | 134 | 135 | class CyclicSendTask(can.broadcastmanager.LimitedDurationCyclicSendTaskABC, 136 | can.broadcastmanager.RestartableCyclicTaskABC, 137 | can.broadcastmanager.ModifiableCyclicTaskABC): 138 | 139 | def __init__(self, bus, message, period, duration=None): 140 | """ 141 | :param bus: The remote connection to use. 142 | :param message: The message to be sent periodically. 143 | :param period: The rate in seconds at which to send the message. 144 | """ 145 | self.bus = bus 146 | super(CyclicSendTask, self).__init__(message, period, duration) 147 | self.start() 148 | 149 | def start(self): 150 | for msg in self.messages: 151 | self.bus.protocol.send_periodic_start(msg, 152 | self.period, 153 | self.duration) 154 | 155 | def stop(self): 156 | self.bus.protocol.send_periodic_stop(self.message.arbitration_id) 157 | 158 | def modify_data(self, message): 159 | assert message.arbitration_id == self.message.arbitration_id 160 | self.message = message 161 | self.bus.protocol.send_periodic_start(self.message, 162 | self.period, 163 | self.duration) 164 | -------------------------------------------------------------------------------- /can_remote/protocol.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import struct 4 | from can import CanError, Message 5 | 6 | from .websocket import WebSocket, WebsocketClosed 7 | 8 | 9 | LOGGER = logging.getLogger(__name__) 10 | 11 | # Timestamp, arbitration ID, DLC, flags 12 | BINARY_MSG_STRUCT = struct.Struct(">dIBB") 13 | BINARY_MESSAGE_TYPE = 1 14 | 15 | IS_EXTENDED_ID = 0x1 16 | IS_REMOTE_FRAME = 0x2 17 | IS_ERROR_FRAME = 0x4 18 | IS_FD = 0x8 19 | IS_BRS = 0x10 20 | IS_ESI = 0x20 21 | 22 | 23 | class RemoteProtocolBase(object): 24 | 25 | def __init__(self, websocket): 26 | self._ws = websocket 27 | self._use_binary = websocket.protocol == "can.binary+json.v1" 28 | 29 | def recv(self, timeout=None): 30 | try: 31 | if not self._ws.wait(timeout): 32 | return None 33 | data = self._ws.read() 34 | if isinstance(data, bytearray): 35 | if data[0] == BINARY_MESSAGE_TYPE: 36 | timestamp, arb_id, dlc, flags = \ 37 | BINARY_MSG_STRUCT.unpack_from(data, 1) 38 | return Message(timestamp=timestamp, 39 | arbitration_id=arb_id, 40 | dlc=dlc, 41 | is_extended_id=bool(flags & IS_EXTENDED_ID), 42 | is_remote_frame=bool(flags & IS_REMOTE_FRAME), 43 | is_error_frame=bool(flags & IS_ERROR_FRAME), 44 | is_fd=bool(flags & IS_FD), 45 | bitrate_switch=bool(flags & IS_BRS), 46 | error_state_indicator=bool(flags & IS_ESI), 47 | data=data[15:]) 48 | else: 49 | return None 50 | event = json.loads(data) 51 | if not isinstance(event, dict): 52 | raise TypeError("Message is not a dictionary") 53 | if "type" not in event: 54 | raise ValueError("Message must contain a 'type' key") 55 | if event["type"] == "error": 56 | raise RemoteError(event["payload"]) 57 | if event["type"] == "message": 58 | return Message(**event["payload"]) 59 | except (ValueError, TypeError, KeyError) as exc: 60 | LOGGER.warning("An error occurred: %s", exc) 61 | self.send_error(exc) 62 | return None 63 | return event 64 | 65 | def send(self, event_type, payload): 66 | self._ws.send(json.dumps({"type": event_type, "payload": payload})) 67 | 68 | def send_msg(self, msg): 69 | if self._use_binary: 70 | flags = 0 71 | if msg.is_extended_id: 72 | flags |= IS_EXTENDED_ID 73 | if msg.is_remote_frame: 74 | flags |= IS_REMOTE_FRAME 75 | if msg.is_error_frame: 76 | flags |= IS_ERROR_FRAME 77 | if msg.is_fd: 78 | flags |= IS_FD 79 | if msg.bitrate_switch: 80 | flags |= IS_BRS 81 | if msg.error_state_indicator: 82 | flags |= IS_ESI 83 | data = BINARY_MSG_STRUCT.pack(msg.timestamp, 84 | msg.arbitration_id, 85 | msg.dlc, 86 | flags) 87 | payload = bytearray([BINARY_MESSAGE_TYPE]) 88 | payload.extend(data) 89 | payload.extend(msg.data) 90 | self._ws.send(payload) 91 | else: 92 | payload = { 93 | "timestamp": msg.timestamp, 94 | "arbitration_id": msg.arbitration_id, 95 | "is_extended_id": msg.is_extended_id, 96 | "is_remote_frame": msg.is_remote_frame, 97 | "is_error_frame": msg.is_error_frame, 98 | "dlc": msg.dlc, 99 | "data": list(msg.data), 100 | } 101 | if msg.is_fd: 102 | payload["is_fd"] = True 103 | payload["bitrate_switch"] = msg.bitrate_switch 104 | payload["error_state_indicator"] = msg.error_state_indicator 105 | self.send("message", payload) 106 | 107 | def send_error(self, exc): 108 | self.send("error", str(exc)) 109 | 110 | def close(self): 111 | self._ws.close() 112 | 113 | def terminate(self, exc): 114 | self._ws.close(1011, str(exc)) 115 | 116 | 117 | class RemoteError(CanError): 118 | pass 119 | -------------------------------------------------------------------------------- /can_remote/server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mimetypes 3 | import os.path 4 | import shutil 5 | import threading 6 | try: 7 | from http.server import HTTPServer, BaseHTTPRequestHandler 8 | from socketserver import ThreadingMixIn 9 | except ImportError: 10 | from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler 11 | from SocketServer import ThreadingMixIn 12 | 13 | import can 14 | from . import DEFAULT_PORT 15 | from .protocol import RemoteProtocolBase 16 | from .websocket import WebSocket, WebsocketClosed, get_accept_key 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def create_connection(url, config=None): 23 | if config is None: 24 | config = {} 25 | headers = {"X-Can-Role": "server"} 26 | websocket = WebSocket(url, ["can.binary+json.v1", "can.json.v1"], headers=headers) 27 | protocol = RemoteServerProtocol(config, websocket) 28 | protocol.run() 29 | 30 | 31 | class RemoteServer(ThreadingMixIn, HTTPServer): 32 | """Server for CAN communication.""" 33 | 34 | daemon_threads = True 35 | 36 | def __init__(self, host='0.0.0.0', port=DEFAULT_PORT, ssl_context=None, **config): 37 | """ 38 | :param str host: 39 | Address to listen to. 40 | :param int port: 41 | Network port to listen to. 42 | :param ssl.SSLContext ssl_context: 43 | An SSL context to use for creating a secure WSS server. 44 | :param channel: 45 | The can interface identifier. Expected type is backend dependent. 46 | :param str bustype: 47 | CAN interface to use. 48 | :param int bitrate: 49 | Forced bitrate in bits/s. 50 | """ 51 | address = (host, port) 52 | self.config = config 53 | #: List of :class:`can.interfaces.remote.server.ClientRequestHandler` 54 | #: instances 55 | self.clients = [] 56 | HTTPServer.__init__(self, address, ClientRequestHandler) 57 | logger.info("Server listening on %s:%d", address[0], address[1]) 58 | if ssl_context: 59 | self.socket = ssl_context.wrap_socket(self.socket, server_side=True) 60 | scheme_suffix = "s" 61 | else: 62 | scheme_suffix = "" 63 | logger.info("Connect using channel 'ws%s://localhost:%d/'", 64 | scheme_suffix, self.server_port) 65 | logger.info("Open browser to 'http%s://localhost:%d/'", 66 | scheme_suffix, self.server_port) 67 | 68 | 69 | class ClientRequestHandler(BaseHTTPRequestHandler): 70 | """A client connection on the server.""" 71 | 72 | server_version = ("python-can/" + can.__version__ + " " + 73 | BaseHTTPRequestHandler.server_version) 74 | 75 | protocol_version = "HTTP/1.1" 76 | 77 | disable_nagle_algorithm = True 78 | 79 | log_message = logger.debug 80 | 81 | def do_GET(self): 82 | if self.headers.get("Upgrade", "").lower() == "websocket": 83 | self.start_websocket() 84 | else: 85 | self.send_trace_webpage() 86 | 87 | def start_websocket(self): 88 | logger.info("Got connection from %s", self.address_string()) 89 | self.send_response(101) 90 | self.send_header("Upgrade", "WebSocket") 91 | self.send_header("Connection", "Upgrade") 92 | self.send_header("Sec-WebSocket-Accept", 93 | get_accept_key(self.headers["Sec-WebSocket-Key"])) 94 | protocols = self.headers.get("Sec-WebSocket-Protocol", "can.json.v1") 95 | protocols = [p.strip() for p in protocols.split(",")] 96 | protocol = "can.binary+json.v1" if "can.binary+json.v1" in protocols else "can.json.v1" 97 | self.send_header("Sec-WebSocket-Protocol", protocol) 98 | self.end_headers() 99 | 100 | websocket = WebSocket(None, protocol, sock=self.request) 101 | protocol = RemoteServerProtocol(self.server.config, websocket) 102 | self.server.clients.append(protocol) 103 | protocol.run() 104 | logger.info("Closing connection to %s", self.address_string()) 105 | # Remove itself from the server's list of clients 106 | self.server.clients.remove(protocol) 107 | 108 | def send_trace_webpage(self): 109 | path = os.path.dirname(__file__) + "/web" + self.path 110 | if path.endswith("/"): 111 | path = path + "index.html" 112 | # Prefer compressed files 113 | if os.path.exists(path + ".gz"): 114 | path = path + ".gz" 115 | if not os.path.exists(path): 116 | self.send_error(404) 117 | return 118 | self.send_response(200) 119 | type, encoding = mimetypes.guess_type(path, strict=False) 120 | if type: 121 | self.send_header("Content-Type", type) 122 | if encoding: 123 | self.send_header("Content-Encoding", encoding) 124 | self.send_header("Content-Length", str(os.path.getsize(path))) 125 | self.end_headers() 126 | with open(path, "rb") as infile: 127 | shutil.copyfileobj(infile, self.wfile) 128 | 129 | 130 | class RemoteServerProtocol(RemoteProtocolBase): 131 | 132 | def __init__(self, config, websocket): 133 | super(RemoteServerProtocol, self).__init__(websocket) 134 | event = self.recv() 135 | if not event or event["type"] != "bus_request": 136 | print(event) 137 | raise RemoteServerError("Client did not send a bus request") 138 | new_config = {} 139 | new_config.update(event["payload"]["config"]) 140 | logger.info("Config received: %r", new_config) 141 | new_config.update(config) 142 | self.config = new_config 143 | try: 144 | self.bus = can.interface.Bus(**new_config) 145 | except Exception as exc: 146 | self.terminate(exc) 147 | raise 148 | logger.info("Connected to bus '%s'", self.bus.channel_info) 149 | self.send_bus_response(self.bus.channel_info) 150 | self.running = True 151 | self._send_tasks = {} 152 | self._send_thread = threading.Thread(target=self._send_to_client) 153 | self._send_thread.daemon = True 154 | 155 | def send_bus_response(self, channel_info): 156 | self.send("bus_response", {"channel_info": channel_info}) 157 | 158 | def run(self): 159 | self._send_thread.start() 160 | try: 161 | self._receive_from_client() 162 | except Exception as exc: 163 | self.terminate(exc) 164 | finally: 165 | self.running = False 166 | if self._send_thread.is_alive(): 167 | self._send_thread.join(3) 168 | 169 | def _send_to_client(self): 170 | """Continuously read CAN messages and send to client.""" 171 | while self.running: 172 | try: 173 | msg = self.bus.recv(0.5) 174 | except Exception as e: 175 | logger.exception(e) 176 | self.send_error(e) 177 | else: 178 | if msg is not None: 179 | self.send_msg(msg) 180 | logger.info('Disconnecting from CAN bus') 181 | self.bus.shutdown() 182 | 183 | def _receive_from_client(self): 184 | """Continuously read events from socket and send messages on CAN bus.""" 185 | while self.running: 186 | try: 187 | event = self.recv() 188 | except WebsocketClosed as exc: 189 | logger.info("Websocket closed: %s", exc) 190 | break 191 | if event is None: 192 | continue 193 | if isinstance(event, can.Message): 194 | self.bus.send(event) 195 | elif event["type"] == "periodic_start": 196 | msg = can.Message(**event["payload"]["msg"]) 197 | arb_id = msg.arbitration_id 198 | if arb_id in self._send_tasks: 199 | # Modify already existing task 200 | self._send_tasks[arb_id].modify_data(msg) 201 | else: 202 | # Create new task 203 | task = self.bus.send_periodic(msg, 204 | event["payload"]["period"], 205 | event["payload"].get("duration")) 206 | self._send_tasks[arb_id] = task 207 | elif event["type"] == "periodic_stop": 208 | self._send_tasks[event["payload"]].stop() 209 | 210 | 211 | class RemoteServerError(Exception): 212 | pass 213 | 214 | 215 | if __name__ == "__main__": 216 | RemoteServer(channel=0, bustype="virtual").serve_forever() 217 | -------------------------------------------------------------------------------- /can_remote/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.2" 2 | -------------------------------------------------------------------------------- /can_remote/web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | assets/*.js 3 | assets/*.map 4 | -------------------------------------------------------------------------------- /can_remote/web/README.md: -------------------------------------------------------------------------------- 1 | # Remote web interface 2 | 3 | ## Build Setup 4 | 5 | Install [Node.js](https://nodejs.org/). 6 | 7 | ``` bash 8 | # install dependencies 9 | npm install 10 | 11 | # serve with hot reload at localhost:8080 12 | npm run dev 13 | 14 | # build for production with minification 15 | npm run build 16 | ``` 17 | -------------------------------------------------------------------------------- /can_remote/web/assets/bundle.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christiansandberg/python-can-remote/edfcf9de1e9ce08cf1e8ef7004cc9ab2ee1ba661/can_remote/web/assets/bundle.js.gz -------------------------------------------------------------------------------- /can_remote/web/assets/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christiansandberg/python-can-remote/edfcf9de1e9ce08cf1e8ef7004cc9ab2ee1ba661/can_remote/web/assets/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /can_remote/web/assets/glyphicons-halflings-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /can_remote/web/assets/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christiansandberg/python-can-remote/edfcf9de1e9ce08cf1e8ef7004cc9ab2ee1ba661/can_remote/web/assets/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /can_remote/web/assets/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christiansandberg/python-can-remote/edfcf9de1e9ce08cf1e8ef7004cc9ab2ee1ba661/can_remote/web/assets/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /can_remote/web/assets/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christiansandberg/python-can-remote/edfcf9de1e9ce08cf1e8ef7004cc9ab2ee1ba661/can_remote/web/assets/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /can_remote/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |79 | Timestamp 80 | Period 81 | | 83 |84 | CAN ID 85 | | 87 |DLC | 88 |Data | 89 |
---|---|---|---|
94 | 95 | {{ msg.timestamp.toFixed(4) }} 96 | 97 | 98 | {{ msg.delta_time.toFixed(4) }} 99 | 100 | | 101 |102 | {{ msg.arbitration_id.toString(16).toUpperCase() }}x 103 | | 104 |105 | {{ msg.dlc }} 106 | | 107 |108 | 109 | Error frame 110 | 111 | 112 | Remote frame 113 | 114 | 115 | {{ msg.data | formatData }} 116 | 117 | | 118 |