├── .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 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | -------------------------------------------------------------------------------- /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 | python-can remote interface 6 | 7 | 8 |
9 |
10 |
11 |

python-can

12 |
13 | 16 | 19 | 22 |
23 |
24 | 27 | 30 | 35 |
36 |
37 |
38 | {{ channelInfo }}
39 | {{ url }} 40 |
41 |
42 | 43 | 70 | 71 | 74 | 75 | 76 | 77 | 78 | 83 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 101 | 104 | 107 | 118 | 119 | 120 |
79 | 80 | 81 | 84 | CAN ID 85 | DLCData
94 | 97 | 100 | 102 | {{ msg.arbitration_id.toString(16).toUpperCase() }} 103 | 105 | {{ msg.dlc }} 106 | 108 | 111 | 114 | 117 |
121 |
122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /can_remote/web/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Bus from 'python-can-remote' 3 | import throttle from 'lodash/throttle' 4 | 5 | import 'bootstrap/dist/css/bootstrap.css' 6 | import './style.css' 7 | 8 | 9 | function onConnect(bus) { 10 | // Do some experimentation here 11 | setInterval(function () { 12 | bus.send({ 13 | arbitration_id: 0x456, 14 | data: [1, 2, 3, 4, 5] 15 | }); 16 | }, 1000); 17 | 18 | bus.send_periodic({ 19 | arbitration_id: 0x123, 20 | data: [0xff, 0xfe, 0xfd] 21 | }, 0.005); 22 | /* 23 | bus.send({ 24 | arbitration_id: 0x123, 25 | extended_id: false, 26 | data: [0x100, 0xfe, 0xfd] 27 | }); 28 | */ 29 | bus.send({ 30 | arbitration_id: 0x6ef, 31 | is_extended_id: false, 32 | is_remote_frame: true, 33 | dlc: 8 34 | }); 35 | 36 | bus.send({ 37 | arbitration_id: 0xabcdef, 38 | is_extended_id: true, 39 | is_error_frame: true 40 | }); 41 | } 42 | 43 | // Temporary array of messages as they are received 44 | var unprocessedMessages = []; 45 | 46 | // Update app.messages with messages from unprocessedMessages[] 47 | function updateMessages() { 48 | var messages = app.messages; 49 | while (unprocessedMessages.length > 0) { 50 | // Get first unprocessed message in queue 51 | var msg = unprocessedMessages.shift(); 52 | // Search for an existing message in the table 53 | var msgFound = false; 54 | for (var i = messages.length - 1; i >= 0; i--) { 55 | var table_msg = messages[i]; 56 | if ((table_msg.arbitration_id == msg.arbitration_id) && 57 | (table_msg.extended_id == msg.extended_id) && 58 | (table_msg.is_error_frame == msg.is_error_frame) && 59 | (table_msg.is_remote_frame == msg.is_remote_frame)) { 60 | // Calculate time since last message 61 | msg.delta_time = msg.timestamp - table_msg.timestamp; 62 | if (app.allMessages) { 63 | // Add message as a new row 64 | messages.push(msg); 65 | } else { 66 | // Update existing row 67 | Vue.set(messages, i, msg); 68 | } 69 | msgFound = true; 70 | break; 71 | } 72 | } 73 | if (!msgFound) { 74 | // Got a new message 75 | msg.delta_time = 0; 76 | messages.push(msg); 77 | } 78 | } 79 | } 80 | 81 | // Throttled function for updating message view in order to reduce the number 82 | // of DOM updates 83 | var updateMessagesThrottled = throttle( 84 | updateMessages, 100, {leading: true, trailing: true}); 85 | 86 | var app = new Vue({ 87 | el: '#app', 88 | data: { 89 | url: document.URL.replace(/^http/i, 'ws'), 90 | bus: null, 91 | connected: false, 92 | absoluteTime: false, 93 | allMessages: false, 94 | showConfig: false, 95 | bitrate: '500000', 96 | channelInfo: '', 97 | messages: [], 98 | error: null 99 | }, 100 | methods: { 101 | connect: function () { 102 | this.clear(); 103 | var config = { 104 | bitrate: parseInt(this.bitrate), 105 | receive_own_messages: true 106 | }; 107 | var bus = this.bus = new Bus(this.url, config); 108 | 109 | bus.on('connect', function (bus) { 110 | app.connected = true; 111 | app.channelInfo = bus.channelInfo; 112 | if (process.env.NODE_ENV !== 'production') { 113 | onConnect(bus); 114 | } 115 | }); 116 | 117 | bus.on('message', function (msg) { 118 | unprocessedMessages.push(msg); 119 | updateMessagesThrottled(); 120 | }); 121 | 122 | bus.on('error', function (error) { 123 | console.error(error); 124 | app.error = error; 125 | }); 126 | 127 | bus.on('close', function () { 128 | app.connected = false; 129 | app.bus = null; 130 | }); 131 | }, 132 | disconnect: function () { 133 | this.bus.shutdown(); 134 | }, 135 | clear: function () { 136 | this.messages = []; 137 | this.error = null; 138 | }, 139 | sortByTime: function () { 140 | this.messages.sort(function (a, b) { 141 | return a.timestamp - b.timestamp; 142 | }); 143 | }, 144 | sortById: function () { 145 | this.messages.sort(function (a, b) { 146 | return a.arbitration_id - b.arbitration_id; 147 | }); 148 | } 149 | }, 150 | filters: { 151 | formatData: function (data) { 152 | var hexData = new Array(data.length); 153 | for (var i = 0; i < data.length; i++) { 154 | var hex = data[i].toString(16).toUpperCase(); 155 | if (hex.length < 2) { 156 | hex = '0' + hex; 157 | } 158 | hexData[i] = hex; 159 | } 160 | return hexData.join(' '); 161 | } 162 | } 163 | }); 164 | -------------------------------------------------------------------------------- /can_remote/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "python-can-remote-interface", 3 | "description": "python-can remote web interface", 4 | "version": "1.1.0", 5 | "author": "Christian Sandberg ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot", 9 | "build": "cross-env NODE_ENV=production webpack --progress --hide-modules" 10 | }, 11 | "dependencies": { 12 | "bootstrap": "^3.3.7", 13 | "lodash": "^4.17.11", 14 | "python-can-remote": "^0.3.0", 15 | "vue": "^2.6.10" 16 | }, 17 | "devDependencies": { 18 | "compression-webpack-plugin": "^2.0.0", 19 | "cross-env": "^5.2.0", 20 | "css-loader": "^2.1.1", 21 | "file-loader": "^3.0.1", 22 | "style-loader": "^0.23.1", 23 | "vue-loader": "^15.7.0", 24 | "vue-template-compiler": "^2.6.10", 25 | "webpack": "^4.30.0", 26 | "webpack-cli": "^3.3.2", 27 | "webpack-dev-server": "^3.3.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /can_remote/web/style.css: -------------------------------------------------------------------------------- 1 | .top { 2 | background-color: #333333; 3 | color: white; 4 | padding-top: 20px; 5 | padding-bottom: 20px; 6 | margin-bottom: 20px; 7 | } 8 | 9 | .connected .top { 10 | background-color: #225522; 11 | } 12 | 13 | h1 { 14 | font-size: 22pt; 15 | margin-top: 0; 16 | margin-bottom: 0; 17 | margin-right: 20px; 18 | float: left; 19 | } 20 | 21 | .bus-name { 22 | text-align: right; 23 | } 24 | 25 | .trace tbody { 26 | font-family: monospace; 27 | } 28 | 29 | .trace .timestamp { 30 | width: 180px; 31 | } 32 | 33 | .trace .can-id { 34 | width: 100px; 35 | } 36 | 37 | .trace .dlc { 38 | width: 50px; 39 | } 40 | -------------------------------------------------------------------------------- /can_remote/web/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | var CompressionPlugin = require('compression-webpack-plugin') 4 | 5 | module.exports = { 6 | mode: 'production', 7 | entry: './main.js', 8 | output: { 9 | path: path.resolve(__dirname, './assets'), 10 | publicPath: 'assets/', 11 | filename: 'bundle.js' 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.vue$/, 17 | loader: 'vue-loader', 18 | options: { 19 | loaders: { 20 | } 21 | // other vue-loader options go here 22 | } 23 | }, 24 | { 25 | test: /\.css$/, 26 | use: [ 27 | 'style-loader', 28 | 'css-loader' 29 | ] 30 | }, 31 | { 32 | test: /\.(png|jpg|gif|svg|woff|woff2|eot|ttf|otf)$/, 33 | loader: 'file-loader', 34 | options: { 35 | name: '[name].[ext]' 36 | } 37 | } 38 | ] 39 | }, 40 | resolve: { 41 | alias: { 42 | 'vue$': 'vue/dist/vue.esm.js' 43 | } 44 | }, 45 | devServer: { 46 | historyApiFallback: true, 47 | noInfo: true 48 | }, 49 | performance: { 50 | hints: false 51 | }, 52 | devtool: '#eval-source-map' 53 | } 54 | 55 | if (process.env.NODE_ENV === 'production') { 56 | module.exports.devtool = '#source-map' 57 | // http://vue-loader.vuejs.org/en/workflow/production.html 58 | module.exports.plugins = (module.exports.plugins || []).concat([ 59 | new CompressionPlugin({ 60 | algorithm: 'gzip', 61 | test: /\.(js)$/, 62 | threshold: 10240, 63 | minRatio: 0.8 64 | }) 65 | ]) 66 | } 67 | -------------------------------------------------------------------------------- /can_remote/websocket.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import logging 4 | import random 5 | import select 6 | import socket 7 | import struct 8 | import sys 9 | import threading 10 | try: 11 | from http.client import HTTPConnection, HTTPSConnection 12 | from urllib.parse import urlparse 13 | except ImportError: 14 | from httplib import HTTPConnection, HTTPSConnection 15 | from urlparse import urlparse 16 | 17 | import can 18 | 19 | 20 | LOGGER = logging.getLogger(__name__) 21 | 22 | STREAM = 0x0 23 | TEXT = 0x1 24 | BINARY = 0x2 25 | CLOSE = 0x8 26 | PING = 0x9 27 | PONG = 0xA 28 | 29 | 30 | def get_accept_key(key): 31 | k = key.encode('ascii') + b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 32 | return base64.b64encode(hashlib.sha1(k).digest()).decode() 33 | 34 | 35 | class WebSocket(object): 36 | """A WebSocket connection. 37 | 38 | Only for internal use! 39 | """ 40 | 41 | def __init__(self, url, protocols=None, headers=None, ssl_context=None, 42 | sock=None): 43 | """ 44 | :param str url: 45 | URL to server 46 | :param list protocols: 47 | List of requested protocols from client 48 | :param dict headers: 49 | Additional headers to send to server 50 | :param ssl.SSLContext ssl_context: 51 | SSL context to use for secure connections 52 | :param socket.socket sock: 53 | An already established socket by a server 54 | """ 55 | self.url = url 56 | self.protocol = None 57 | self.socket = sock 58 | # Use masking if we are a WebSocket client 59 | self.mask = sock is None 60 | self.closed = False 61 | self._ssl = False 62 | self._send_lock = threading.Lock() 63 | if sock is None: 64 | self.connect(protocols, headers, ssl_context) 65 | else: 66 | self.protocol = protocols 67 | 68 | def connect(self, protocols, headers=None, ssl_context=None): 69 | o = urlparse(self.url, scheme="ws") 70 | if o.scheme == "wss": 71 | conn = HTTPSConnection(o.netloc, context=ssl_context) 72 | self._ssl = True 73 | else: 74 | conn = HTTPConnection(o.netloc) 75 | if headers is None: 76 | headers = {} 77 | rand = bytes(random.getrandbits(8) for _ in range(16)) 78 | key = base64.b64encode(rand).decode() 79 | headers["User-Agent"] = "python-can/%s (%s)" % ( 80 | can.__version__, sys.platform) 81 | headers["Upgrade"] = "WebSocket" 82 | headers["Connection"] = "Upgrade" 83 | headers["Sec-WebSocket-Key"] = key 84 | headers["Sec-WebSocket-Version"] = "13" 85 | if protocols: 86 | headers["Sec-WebSocket-Protocol"] = ",".join(protocols) 87 | conn.request("GET", o.path, headers=headers) 88 | res = conn.getresponse() 89 | LOGGER.debug("Server response headers:\n%s", res.msg) 90 | assert res.status == 101, "Unexpected response status" 91 | assert res.getheader("Upgrade").lower() == "websocket" 92 | assert res.getheader("Sec-WebSocket-Accept") == get_accept_key(key) 93 | self.protocol = res.getheader("Sec-WebSocket-Protocol") 94 | if protocols: 95 | assert self.protocol in protocols, "Unsupported protocol" 96 | self.socket = conn.sock 97 | 98 | def _read_exactly(self, n): 99 | buf = bytearray(n) 100 | view = memoryview(buf) 101 | nread = 0 102 | while nread < n: 103 | received = self.socket.recv_into(view[nread:]) 104 | if received == 0: 105 | raise WebsocketClosed(1006, "Socket closed unexpectedly") 106 | nread += received 107 | return buf 108 | 109 | def wait(self, timeout=None): 110 | """Wait for data to be available on the socket.""" 111 | return len(select.select([self.socket], [], [], timeout)[0]) > 0 112 | 113 | def read_frame(self): 114 | b1, b2 = self._read_exactly(2) 115 | opcode = b1 & 0xF 116 | mask_used = b2 & 0x80 117 | length = b2 & 0x7F 118 | if length == 126: 119 | length, = struct.unpack(">H", self._read_exactly(2)) 120 | elif length == 127: 121 | length, = struct.unpack(">Q", self._read_exactly(8)) 122 | if mask_used: 123 | mask = self._read_exactly(4) 124 | data = self._read_exactly(length) 125 | if mask_used: 126 | for i, b in enumerate(data): 127 | data[i] = b ^ mask[i % 4] 128 | return opcode, data 129 | 130 | def send_frame(self, opcode, data=b""): 131 | length = len(data) 132 | payload = bytearray(2) 133 | payload[0] = opcode | 0x80 134 | if length <= 125: 135 | payload[1] = length 136 | elif length <= 65535: 137 | payload[1] = 126 138 | payload.extend(struct.pack(">H", length)) 139 | else: 140 | payload[1] = 127 141 | payload.extend(struct.pack(">Q", length)) 142 | if self.mask: 143 | mask = [random.getrandbits(8) for _ in range(4)] 144 | payload[1] |= 0x80 145 | payload.extend(mask) 146 | for i, b in enumerate(data): 147 | if not isinstance(b, int): 148 | # If Python 2.7 149 | b = ord(b) 150 | payload.append(b ^ mask[i % 4]) 151 | else: 152 | payload.extend(data) 153 | with self._send_lock: 154 | if not self.closed: 155 | self.socket.sendall(payload) 156 | 157 | def read(self): 158 | """Read next message 159 | 160 | :return: 161 | A string or bytearray depending on if it is a binary or text message 162 | """ 163 | while True: 164 | opcode, data = self.read_frame() 165 | if opcode == TEXT: 166 | return data.decode("utf-8") 167 | elif opcode == BINARY: 168 | return data 169 | elif opcode == PING: 170 | LOGGER.debug("PING") 171 | self.send_frame(PONG, data) 172 | elif opcode == CLOSE: 173 | if not data: 174 | status = 1000 175 | reason = "" 176 | else: 177 | status, = struct.unpack_from(">H", data) 178 | reason = data[2:].decode("utf-8") 179 | self.close(status, reason) 180 | self.socket.close() 181 | raise WebsocketClosed(status, reason) 182 | 183 | def send(self, obj): 184 | """Send a message. 185 | 186 | :param obj: 187 | A binary or text object to send 188 | :type obj: str, bytearray 189 | """ 190 | if isinstance(obj, bytearray): 191 | self.send_frame(BINARY, obj) 192 | else: 193 | self.send_frame(TEXT, obj.encode("utf-8")) 194 | 195 | def close(self, status=None, reason=""): 196 | """Close connection.""" 197 | if not self.closed: 198 | if status: 199 | payload = struct.pack(">H", status) + reason.encode("utf-8") 200 | else: 201 | payload = b"" 202 | try: 203 | self.send_frame(CLOSE, payload) 204 | finally: 205 | with self._send_lock: 206 | # SSL does not support half-close connections 207 | if not self._ssl: 208 | self.socket.shutdown(socket.SHUT_WR) 209 | self.closed = True 210 | 211 | 212 | class WebsocketClosed(Exception): 213 | """Websocket was closed either gracefully or unexpectedly.""" 214 | 215 | def __init__(self, code, reason): 216 | self.code = code 217 | self.reason = reason 218 | 219 | def __str__(self): 220 | return "Status: %d, reason: %s" % (self.code, self.reason) 221 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | exec(open("can_remote/version.py").read()) 4 | 5 | description = open("README.rst").read() 6 | 7 | setup( 8 | name="python-can-remote", 9 | url="https://github.com/christiansandberg/python-can-remote", 10 | version=__version__, 11 | packages=find_packages(), 12 | author="Christian Sandberg", 13 | author_email="christiansandberg@me.com", 14 | description="CAN over network bridge for Python", 15 | keywords="CAN TCP websocket", 16 | long_description=description, 17 | license="MIT", 18 | platforms=["any"], 19 | classifiers=[ 20 | "Development Status :: 4 - Beta", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python :: 2", 24 | "Programming Language :: Python :: 3", 25 | "Intended Audience :: Developers", 26 | "Topic :: Scientific/Engineering" 27 | ], 28 | package_data={ 29 | "can_remote": ["web/index.html", "web/assets/*"] 30 | }, 31 | entry_points={ 32 | "can.interface": [ 33 | "remote=can_remote.client:RemoteBus", 34 | ] 35 | }, 36 | install_requires=["python-can>=3.0.0"] 37 | ) 38 | --------------------------------------------------------------------------------