From 11f62a2cb8cd137e1b8d77a5e22476a46f3e9d5b Mon Sep 17 00:00:00 2001
From: ccdunder <cdunder@gmail.com>
Date: Sun, 5 Jan 2025 18:32:28 -0800
Subject: [PATCH 1/2] Add pycan support for panda.

This allows one to use a whole ecosystem of oss tools for canfd analysis like CaringCaribou.

This impl also includes a simple bidi relay to socketcan. This is useful on C3X which isn't supported by socketcan kernel driver.
---
 python/pycanpanda.py | 218 +++++++++++++++++++++++++++++++++++++++++++
 setup.py             |   5 +
 2 files changed, 223 insertions(+)
 create mode 100755 python/pycanpanda.py

diff --git a/python/pycanpanda.py b/python/pycanpanda.py
new file mode 100755
index 0000000000..f8bba8669c
--- /dev/null
+++ b/python/pycanpanda.py
@@ -0,0 +1,218 @@
+"""
+python-can interface for openpilot/panda.
+
+"""
+
+from typing import Any, Optional, Tuple
+
+from can.exceptions import error_check
+
+import struct
+import time
+import logging
+from collections import deque
+
+import can
+from can import Message, typechecking
+
+logger = logging.getLogger(__name__)
+
+from panda import Panda
+from opendbc.car.can_definitions import CanData
+
+FILTERING_DONE = False
+
+# Panda.SAFETY_ELM327 will multioplex bus 1 to the OBD-II port,
+# unless its safety_param is set to 1.
+
+# panda.set_obd(True) will multiplex.
+
+class PandaBus(can.BusABC):
+  """CAN bus from a Panda by comma.ai."""
+
+  def __init__(
+      self,
+      channel: Any,
+      can_filters: Optional[can.typechecking.CanFilters] = None,
+      serial: str | None = None,
+      claim: bool = False,
+      disable_checks: bool = True,
+      safety_mode: int = Panda.SAFETY_NOOUTPUT,
+      safety_param: int = 0,
+      can_speed_kbps: int = 500,
+      cli: bool = True,
+      **kwargs: object,
+  ):
+    """
+    Construct and open a Panda instance.
+
+    :param channel: Panda bus; called channel by super.
+    :param can_filters: From super.
+    :param serial: Panda serial number.
+    :param claim: Panda whether to claim.
+    :param disable_checks: Panda whether to disable checks.
+    :param can_speed_kbps: Panda CAN speed.
+    :param safety_mode: Panda safety mode.
+
+    :raises ValueError: If parameters are out of range
+    :raises ~can.exceptions.CanInterfaceNotImplementedError:
+        If the driver cannot be accessed
+    :raises ~can.exceptions.CanInitializationError:
+        If the bus cannot be initialized
+    """
+
+    # Panda recv returns multiple messages but pycan recv should return one.
+    # TODO: Use a Queue for better clarity.
+    self._recv_buffer = deque()
+
+    self.p = Panda()
+    #self.p.reset()
+    #self.p.set_canfd_auto(self.bus, True)
+    if channel == 3:
+      self.p.set_obd(True)
+      channel = 1
+    self.bus = channel
+    self.channel = channel
+    self.p.can_clear(self.bus) # TX queue
+    self.p.can_clear(0xFFFF) # RX queue
+    self.p.set_safety_mode(safety_mode, safety_param)
+
+    # Init for super.
+    self._can_protocol = can.CanProtocol.CAN_FD
+    self.channel_info = f"Panda: serial {self.p.get_serial()}, channel {self.channel}"
+    # Must be run last.
+    super().__init__(channel, can_filters=can_filters)
+
+  def _recv_internal(
+      self, timeout: Optional[float]
+  ) -> Tuple[Optional[Message], bool]:
+    """Block waiting for a message from the Bus.
+
+    :param float timeout: Seconds to wait for a message.
+                          None blocks indefinitely.
+                          0.0 is non-blocking.
+
+    :return:
+        None on timeout or a Message object.
+    :rtype: can.Message
+    :raises can.interfaces.remote.protocol.RemoteError:
+    """
+    end_time = None if timeout is None else time.time() + timeout
+    while len(self._recv_buffer) == 0:
+      # TODO: handle 0.0
+      if timeout is not None and time.time() > end_time:
+        return None, FILTERING_DONE
+      recv = self.p.can_recv()
+      recv_this_bus = [r for r in recv if r[2] == self.bus]
+      self._recv_buffer.extend(recv_this_bus)
+    candata = self._recv_buffer.popleft()
+    msg = panda_to_pycan(candata)
+    return msg, FILTERING_DONE
+
+  def send(self, msg: Message, timeout: Optional[float] = None) -> None:
+    """Transmit a message to the CAN bus.
+
+    :param Message msg: A message object.
+
+    :param timeout:
+        If > 0, wait up to this many seconds for message to be ACK'ed or
+        for transmit queue to be ready depending on driver implementation.
+        If timeout is exceeded, an exception will be raised.
+        Might not be supported by all interfaces.
+        None blocks indefinitely.
+
+    :raises ~can.exceptions.CanOperationError:
+        If an error occurred while sending
+    """
+    timeout = timeout or 0
+    addr, data, bus = pycan_to_panda(msg, self.bus)
+    self.p.can_send(addr, data, bus,
+                    fd=msg.is_fd,
+                    timeout=timeout*1000)
+
+  def shutdown(self):
+    self.p.set_safety_mode(Panda.SAFETY_SILENT)
+    self.p.close()
+    # Must be run last.
+    super().shutdown()
+
+def panda_to_pycan(candata: CanData) -> Message:
+  #addr, data, bus, is_remote = candata
+  addr, data, bus = candata
+  is_remote = False
+  return Message(channel=bus,
+                 arbitration_id=addr,
+                 is_extended_id=addr >= 0x800,
+                 is_remote_frame=is_remote,
+                 data=data,
+                 is_fd=True,
+                 timestamp=time.time(),
+                 check=True)
+
+def pycan_to_panda(msg: Message, bus) -> CanData:
+  return msg.arbitration_id, msg.data, bus
+
+
+# in_bus.recv => socketcan.send
+# socketcan.recv => in_bus.send
+def to_socketcan(in_bus: can.Bus):
+  out_bus = can.Bus(channel='vcan0', interface='socketcan',
+                                        local_loopback=True,
+                                        fd=True)
+  abort = False
+  next_in: Message
+  next_out: Message
+  while not abort:
+    if next_in is None:
+      next_in = in_bus.recv(timeout=0.0)
+    if next_out is None:
+      next_out = out_bus.recv(timeout=0.0)
+
+    while next_in is not None and next_out is not None \
+      and next_in.timestamp < next_out.timestamp:
+        out_bus.send(next_in)
+        next_in = in_bus.recv(timeout=0.0)
+    while next_in is not None and next_out is not None \
+      and next_in.timestamp > next_out.timestamp:
+        in_bus.send(next_out)
+        next_out = out_bus.recv(timeout=0.0)
+
+
+if __name__ == '__main__':
+    with PandaBus(
+      interface="panda",
+      channel = 1) as bus:
+
+        to_socketcan(bus)
+
+        # bus.send(can.Message(
+        #     arbitration_id=0x7DF,
+        #     is_extended_id=False,
+        #     channel=0,
+        #     data=[0x02, 0x10, 0x03, ],
+        #     dlc=3,
+        # ))
+
+        start = time.monotonic()
+        while time.monotonic() - start < 10:
+            _msg = bus.recv()
+            print(_msg)
+
+
+
+# install: pip install -e panda
+
+
+## mirror to socketcan:
+# sudo modprobe vcan
+# # Create a vcan network interface with a specific name
+# sudo ip link add dev vcan0 type vcan
+# sudo ip link set vcan0 up
+# pac can-utils
+# cansend vcan0 123#DEADBEEF
+
+# export CAN_INTERFACE=socketcan
+# export CAN_CHANNEL
+# export CAN_BITRATE
+# export CAN_CONFIG={"receive_own_messages": true, "fd": true}
+
diff --git a/setup.py b/setup.py
index a7729cc6f8..09fd777d00 100644
--- a/setup.py
+++ b/setup.py
@@ -75,4 +75,9 @@ def find_version(*file_paths):
     "Programming Language :: Python :: 3",
     "Topic :: System :: Hardware",
   ],
+  entry_points={
+    "can.interface": [
+      "panda=panda.python.pycanpanda:PandaBus",
+    ]
+  },
 )

From efc7da76599495b8a6089ec37c798510ec074ab0 Mon Sep 17 00:00:00 2001
From: ccdunder <ccdunder@users.noreply.github.com>
Date: Fri, 24 Jan 2025 06:38:29 +0000
Subject: [PATCH 2/2] clean up

---
 python/__init__.py                 |  6 +-
 python/{pycanpanda.py => pycan.py} | 89 ++++++++++++++----------------
 python/socketpanda.py              |  4 ++
 setup.py                           |  3 +-
 4 files changed, 50 insertions(+), 52 deletions(-)
 rename python/{pycanpanda.py => pycan.py} (73%)

diff --git a/python/__init__.py b/python/__init__.py
index 36227269b7..57791d1050 100644
--- a/python/__init__.py
+++ b/python/__init__.py
@@ -9,7 +9,7 @@
 from functools import wraps, partial
 from itertools import accumulate
 
-from .base import BaseHandle
+from .base import TIMEOUT, BaseHandle
 from .constants import FW_PATH, McuType
 from .dfu import PandaDFU
 from .isotp import isotp_send, isotp_recv
@@ -846,11 +846,11 @@ def can_send(self, addr, dat, bus, *, fd=False, timeout=CAN_SEND_TIMEOUT_MS):
     self.can_send_many([[addr, dat, bus]], fd=fd, timeout=timeout)
 
   @ensure_can_packet_version
-  def can_recv(self):
+  def can_recv(self, timeout=TIMEOUT):
     dat = bytearray()
     while True:
       try:
-        dat = self._handle.bulkRead(1, 16384) # Max receive batch size + 2 extra reserve frames
+        dat = self._handle.bulkRead(1, 16384, timeout) # Max receive batch size + 2 extra reserve frames
         break
       except (usb1.USBErrorIO, usb1.USBErrorOverflow):
         logger.error("CAN: BAD RECV, RETRYING")
diff --git a/python/pycanpanda.py b/python/pycan.py
similarity index 73%
rename from python/pycanpanda.py
rename to python/pycan.py
index f8bba8669c..4422fc78a8 100755
--- a/python/pycanpanda.py
+++ b/python/pycan.py
@@ -1,5 +1,8 @@
 """
-python-can interface for openpilot/panda.
+Make a panda accessible via socketcan & python-can.
+
+This allows using tools like wireshark, caringcaribou, etc. with panda,
+which can be very useful when working with higher-level message protocols over CAN.
 
 """
 
@@ -20,39 +23,29 @@
 from panda import Panda
 from opendbc.car.can_definitions import CanData
 
+# Signal to pycan to do software filtering because we haven't.
 FILTERING_DONE = False
 
-# Panda.SAFETY_ELM327 will multioplex bus 1 to the OBD-II port,
-# unless its safety_param is set to 1.
-
-# panda.set_obd(True) will multiplex.
-
 class PandaBus(can.BusABC):
-  """CAN bus from a Panda by comma.ai."""
+  """python-can bus for a openpilot panda."""
 
   def __init__(
       self,
       channel: Any,
       can_filters: Optional[can.typechecking.CanFilters] = None,
-      serial: str | None = None,
-      claim: bool = False,
-      disable_checks: bool = True,
       safety_mode: int = Panda.SAFETY_NOOUTPUT,
       safety_param: int = 0,
-      can_speed_kbps: int = 500,
-      cli: bool = True,
       **kwargs: object,
   ):
     """
     Construct and open a Panda instance.
 
     :param channel: Panda bus; called channel by super.
+        Set to 3 to multiplex OBD.
     :param can_filters: From super.
-    :param serial: Panda serial number.
-    :param claim: Panda whether to claim.
-    :param disable_checks: Panda whether to disable checks.
-    :param can_speed_kbps: Panda CAN speed.
     :param safety_mode: Panda safety mode.
+    :param safety_param: Panda safety param.
+    :param **kwargs: Passed to Panda().
 
     :raises ValueError: If parameters are out of range
     :raises ~can.exceptions.CanInterfaceNotImplementedError:
@@ -60,14 +53,8 @@ def __init__(
     :raises ~can.exceptions.CanInitializationError:
         If the bus cannot be initialized
     """
-
-    # Panda recv returns multiple messages but pycan recv should return one.
-    # TODO: Use a Queue for better clarity.
-    self._recv_buffer = deque()
-
-    self.p = Panda()
-    #self.p.reset()
-    #self.p.set_canfd_auto(self.bus, True)
+    # Init Panda
+    self.p = Panda(**kwargs)
     if channel == 3:
       self.p.set_obd(True)
       channel = 1
@@ -76,10 +63,11 @@ def __init__(
     self.p.can_clear(self.bus) # TX queue
     self.p.can_clear(0xFFFF) # RX queue
     self.p.set_safety_mode(safety_mode, safety_param)
+    self._recv_buffer = deque()
 
     # Init for super.
     self._can_protocol = can.CanProtocol.CAN_FD
-    self.channel_info = f"Panda: serial {self.p.get_serial()}, channel {self.channel}"
+    self.channel_info = f"Panda: serial {self.p.get_serial()}, bus {self.bus}"
     # Must be run last.
     super().__init__(channel, can_filters=can_filters)
 
@@ -97,14 +85,12 @@ def _recv_internal(
     :rtype: can.Message
     :raises can.interfaces.remote.protocol.RemoteError:
     """
-    end_time = None if timeout is None else time.time() + timeout
-    while len(self._recv_buffer) == 0:
-      # TODO: handle 0.0
-      if timeout is not None and time.time() > end_time:
-        return None, FILTERING_DONE
-      recv = self.p.can_recv()
+    if not self._recv_buffer:
+      recv = self.p.can_recv(timeout=pycan_timeout_to_panda(timeout))
       recv_this_bus = [r for r in recv if r[2] == self.bus]
       self._recv_buffer.extend(recv_this_bus)
+    if not self._recv_buffer:
+      return None, FILTERING_DONE
     candata = self._recv_buffer.popleft()
     msg = panda_to_pycan(candata)
     return msg, FILTERING_DONE
@@ -124,11 +110,10 @@ def send(self, msg: Message, timeout: Optional[float] = None) -> None:
     :raises ~can.exceptions.CanOperationError:
         If an error occurred while sending
     """
-    timeout = timeout or 0
     addr, data, bus = pycan_to_panda(msg, self.bus)
     self.p.can_send(addr, data, bus,
                     fd=msg.is_fd,
-                    timeout=timeout*1000)
+                    timeout=pycan_timeout_to_panda(timeout))
 
   def shutdown(self):
     self.p.set_safety_mode(Panda.SAFETY_SILENT)
@@ -137,9 +122,8 @@ def shutdown(self):
     super().shutdown()
 
 def panda_to_pycan(candata: CanData) -> Message:
-  #addr, data, bus, is_remote = candata
   addr, data, bus = candata
-  is_remote = False
+  is_remote = False  # Not currently exposed by panda.
   return Message(channel=bus,
                  arbitration_id=addr,
                  is_extended_id=addr >= 0x800,
@@ -152,16 +136,26 @@ def panda_to_pycan(candata: CanData) -> Message:
 def pycan_to_panda(msg: Message, bus) -> CanData:
   return msg.arbitration_id, msg.data, bus
 
+def pycan_timeout_to_panda(timeout: Optional[float]) -> int:
+  if timeout is None:
+    # block forever
+    return 0
+  if timeout == 0:
+    # non-blocking
+    return -1
+  else:
+    # sec to millis
+    return int(timeout*1e3)
 
 # in_bus.recv => socketcan.send
 # socketcan.recv => in_bus.send
-def to_socketcan(in_bus: can.Bus):
-  out_bus = can.Bus(channel='vcan0', interface='socketcan',
-                                        local_loopback=True,
-                                        fd=True)
+def connect_to_socketcan(in_bus: can.Bus, socketcan_if='vcan0'):
+  out_bus = can.Bus(channel=socketcan_if, interface='socketcan',
+                    local_loopback=True, fd=True)
   abort = False
   next_in: Message
   next_out: Message
+  # This isn't the best bidi message passing, but it'll do for now.
   while not abort:
     if next_in is None:
       next_in = in_bus.recv(timeout=0.0)
@@ -179,11 +173,9 @@ def to_socketcan(in_bus: can.Bus):
 
 
 if __name__ == '__main__':
-    with PandaBus(
-      interface="panda",
-      channel = 1) as bus:
+    with PandaBus(interface="panda", channel = 1) as bus:
 
-        to_socketcan(bus)
+      connect_to_socketcan(bus)
 
         # bus.send(can.Message(
         #     arbitration_id=0x7DF,
@@ -193,14 +185,15 @@ def to_socketcan(in_bus: can.Bus):
         #     dlc=3,
         # ))
 
-        start = time.monotonic()
-        while time.monotonic() - start < 10:
-            _msg = bus.recv()
-            print(_msg)
+      start = time.monotonic()
+      while time.monotonic() - start < 10:
+          _msg = bus.recv()
+          print(_msg)
 
 
 
-# install: pip install -e panda
+# install prereqs:
+# pip install -e '.[dev]'
 
 
 ## mirror to socketcan:
diff --git a/python/socketpanda.py b/python/socketpanda.py
index d6115acf55..614b7ff336 100644
--- a/python/socketpanda.py
+++ b/python/socketpanda.py
@@ -1,3 +1,7 @@
+"""
+Make a socketcan interface look like a panda.
+
+"""
 import socket
 import struct
 
diff --git a/setup.py b/setup.py
index 09fd777d00..11121a29a1 100644
--- a/setup.py
+++ b/setup.py
@@ -52,6 +52,7 @@ def find_version(*file_paths):
       "pycryptodome >= 3.9.8",
       "cffi",
       "flaky",
+      "python-can",
       "pytest",
       "pytest-mock",
       "pytest-xdist",
@@ -77,7 +78,7 @@ def find_version(*file_paths):
   ],
   entry_points={
     "can.interface": [
-      "panda=panda.python.pycanpanda:PandaBus",
+      "panda=panda.python.pycan:PandaBus",
     ]
   },
 )