Skip to content

Commit 870e765

Browse files
committed
Add license, copyright notices and docs
1 parent 16cdef1 commit 870e765

5 files changed

Lines changed: 160 additions & 27 deletions

File tree

LICENSE

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Copyright (c) 2021 Angus Gratton
2+
3+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4+
5+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6+
7+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8+
9+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10+
11+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

README.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
# Canalyst-II Driver for Python
22

3-
Unofficial Python userspace driver for the low cost USB analyzer "Canalyst-II".
3+
Unofficial Python userspace driver for the low cost USB analyzer "Canalyst-II" by Chuangxin Technology (创芯科技).
44

5-
Uses [pyusb](https://pyusb.github.io/pyusb/) library for USB support, should work on Windows, MacOS and Linux.
5+
Uses [pyusb](https://pyusb.github.io/pyusb/) library for USB support on Windows, MacOS and Linux.
66

7-
This driver is based on black box reverse engineering and the original python-can canalystii source. It's mostly intended for use with python-can, but can also be used standalone.
7+
This driver is based on black box reverse engineering of the USB behaviour of the proprietary software, and reading the basic data structure layouts in the original python-can canalystii source.
88

9-
## Usage
9+
Intended for use as a backend driver for [python-can](https://python-can.readthedocs.io/). However it can also be used standalone.
10+
11+
## Standalone Usage
1012

1113
```py
1214
import canalystii
1315

1416
# Connect to the Canalyst-II device
15-
dev = canalystii.CanalystDevice(bitrate=500*1000)
17+
# Passing a bitrate to the constructor causes both channels to be initialized and started.
18+
dev = canalystii.CanalystDevice(bitrate=500000)
1619

1720
# Receive all pending messages on channel 0
1821
for msg in dev.receive(0):
@@ -26,16 +29,31 @@ new_message = canalystii.Message(can_id=0x300,
2629
data=(0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08))
2730
# Send one copy to channel 1
2831
dev.send(1, new_message)
29-
# Send 3 copies to channel 0 (argument can be any iterable, or single instance of canalystii.Message)
32+
# Send 3 copies to channel 0 (argument can be an instance of canalystii.Message or a list of instances)
3033
dev.send(0, [new_message] * 3)
34+
35+
# Stop both channels (need to call start() again to resume capturing or send any messages)
36+
dev.stop(0)
37+
dev.stop(1)
3138
```
3239

40+
## Limitations
41+
42+
Currently, the following things are either not possible or not easy to support based on the known Canalyst-II protocol:
43+
44+
* CAN bus error conditions. There is a function `get_can_status()` that seems to provide access to some internal device state, not clear if this can be used to determine when errors occured or invalid messages seen.
45+
* Receive buffer hardware overflow detection (see Performance, below).
46+
* ACK status of sent CAN messages.
47+
* Failure status of sent CAN messages. If the device fails to get bus arbitration after some unknown amount of time, it will drop the message silently.
48+
* Hardware filtering of incoming messages. Currently all filtering is done in software. There is a `filter` field of `InitCommand` structure, not clear how it works.
49+
* Configuring whether messages are ACKed by Canalyst-II. This may be possible, see `InitCommand` `acc_code` and `acc_mask`.
50+
3351
## Performance
3452

35-
Because the Canalyst-II USB protocol requires polling, there is a trade-off between CPU usage and both latency and maximum receive throughput. The host needs to constantly poll the device to request any new CAN messages.
53+
Because the Canalyst-II USB protocol requires polling, the host needs to constantly poll the device to request any new CAN messages. There is a trade-off of CPU usage against both latency and maximum receive throughput.
3654

37-
The hardware seems able to buffer 1000-2000 messages (possibly a little more) per channel. The maximum number seems to depend on relative timing of the messages. Therefore, a 1Mbps (maximum speed) CAN channel receiving the maximum possible ~7800 messages/second should call `receive()` at least every 100ms in order to avoid lost messages. The USB protocol doesn't provide any way to tell if any messages in the hardware buffer were lost.
55+
The hardware seems able to buffer 1000-2000 messages (possibly a little more) per channel. The maximum number seems to depend on relative timing of the messages. Therefore, if a 1Mbps (maximum speed) CAN channel is receiving the maximum possible ~7800 messages/second then software should call `receive()` at least every 100ms in order to avoid lost messages. It's not possible to tell if any messages in the hardware buffer were lost due to overflow.
3856

39-
Testing Linux CPython 3.9 on an older i7-6500U CPU, calling `receive()` in a tight loop while receiving maximum message rate (~7800 messages/sec) on both channels (~15600 messages/sec total) uses approximately 40% of a single CPU. Adding a 50ms delay `time.sleep(0.05)` in the loop drops CPU usage to around 10% without losing any messages. Longer sleep periods in the loop reduce CPU usage further but some messages are dropped. See the `tests/can_spammer_test.py` file for the test code.
57+
Testing Linux CPython 3.9 on a i7-6500U CPU (~2016 vintage), calling `receive()` in a tight loop while receiving maximum message rate (~7800 messages/sec) on both channels (~15600 messages/sec total) uses approximately 40% of a single CPU. Adding a 50ms delay `time.sleep(0.05)` in the loop drops CPU usage to around 10% without losing any messages. Longer sleep periods in the loop reduce CPU usage further but some messages are dropped. See the `tests/can_spammer_test.py` file for the test code.
4058

4159
In systems where the CAN message rate is lower than the maximum, `receive()` can be called less frequently without losing messages. In systems where the Python process may be pre-empted, it's possible for messages to be lost anyhow.

canalystii/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
1+
# Copyright (c) 2021 Angus Gratton
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# This module contains the public package-level interface to Canalyst-II device.
6+
17
from .protocol import Message # noqa: F401
28
from .device import CanalystDevice # noqa: F401

canalystii/device.py

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Copyright (c) 2021 Angus Gratton
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# This module contains device-level interface to Canalyst-II
16
import ctypes
27
import logging
38
import usb.core
@@ -13,6 +18,24 @@
1318

1419

1520
class CanalystDevice(object):
21+
"""Encapsulates a low-level USB interface to a Canalyst-II device.
22+
23+
Constructing an instance of this class will cause pyusb to acquire the
24+
relevant USB interface, and retain it until the object is garbage collected.
25+
26+
:param:device:_index if more than one Canalyst-II device is connected, this is
27+
the index to use in the list.
28+
:param:usb_device: Optional argument to ignore device_index and provide an instance
29+
of a pyusb device object directly.
30+
:param:bitrate: If set, both channels are initialized to the specified bitrate and
31+
started automatically. If unset (default) then the "init" method must be called
32+
before using either channel.
33+
:param:timing0: Optional parameter to provide BTR timing directly. Either both or
34+
neither timing0 and timing1 must be set, and setting these arguments is mutually
35+
exclusive with setting bitrate. If set, both channels are initialized and started
36+
automatically.
37+
"""
38+
1639
# Small optimization, build common command packet one time
1740
COMMAND_MESSAGE_STATUS = protocol.SimpleCommand(protocol.COMMAND_MESSAGE_STATUS)
1841

@@ -21,6 +44,7 @@ class CanalystDevice(object):
2144
def __init__(
2245
self, device_index=0, usb_device=None, bitrate=None, timing0=None, timing1=None
2346
):
47+
"""Constructor function."""
2448
if usb_device is not None:
2549
self._dev = usb_device
2650
else:
@@ -80,16 +104,28 @@ def __del__(self):
80104
def clear_rx_buffer(self, channel):
81105
"""Clears the device's receive buffer for the specified channel.
82106
83-
Note that this doesn't seem to 100% work, it's possible to get a small number of messages even
107+
Note that this doesn't seem to 100% work in the device firmware, on a busy bus
108+
it's possible to receive a small number of "old" messages even after calling this.
109+
110+
:param:channel: Channel (0 or 1) to clear the RX buffer on.
84111
"""
85112
self.send_command(
86113
channel, protocol.SimpleCommand(protocol.COMMAND_CLEAR_RX_BUFFER)
87114
)
88115

89116
def flush_tx_buffer(self, channel, timeout=0):
90-
"""Return only after all messages have been sent to this channel or timeout is reached.
91-
92-
Returns True if flush is successful, False if timeout was reached. Pass 0 timeout to poll once only.
117+
"""Check if all pending messages have left the hardware TX buffer and optionally keep polling until
118+
this happens or a timeout is reached.
119+
120+
Note that due to hardware limitations, "no messages in TX buffer" doesn't necessarily mean
121+
that the messages were sent successfully - for the default send type 0 (see Message.send_type), the
122+
hardware will attempt bus arbitration multiple times but if it fails then it will still "send" the
123+
message. It also doesn't consider the ACK status of the message.
124+
125+
:param:channel: Channel (0 or 1) to flush the TX buffer on.
126+
:param:timeout: Optional number of seconds to continue polling for empty TX buffer. If 0 (default),
127+
this function will immediately return the current status of the send buffer.
128+
:return: True if flush is successful (no pending messages to send), False if flushing timed out.
93129
"""
94130
deadline = None
95131
while deadline is None or time.time() < deadline:
@@ -106,6 +142,15 @@ def flush_tx_buffer(self, channel, timeout=0):
106142
return False # timeout!
107143

108144
def send_command(self, channel, command_packet, response_class=None):
145+
"""Low-level function to send a command packet to the channel and optionally wait for a response.
146+
147+
:param:channel: Channel (0 or 1) to flush the TX buffer on.
148+
:param:command_packet: Data to send to the channel. Usually this will be a ctypes Structure, but can be
149+
anything that supports a bytes buffer interface.
150+
:param:response_class: If None (default) then this function doesn't expect to read anything back from the
151+
device. If not None, should be a ctypes class - 64 bytes will be read into a buffer and returned as an
152+
object of this type.
153+
"""
109154
ep = CHANNEL_TO_COMMAND_EP[channel]
110155
self._dev.write(ep, memoryview(command_packet).cast("B"))
111156
if response_class:
@@ -118,9 +163,18 @@ def send_command(self, channel, command_packet, response_class=None):
118163

119164
def init(self, channel, bitrate=None, timing0=None, timing1=None, start=True):
120165
"""Initialize channel to a particular baud rate. This can be called more than once to change
121-
the baud rate of an active channel.
122-
123-
By default, the channel is started after being initialized (set start parameter to False to not do this.)
166+
the channel bit rate.
167+
168+
:param:channel: Channel (0 or 1) to initialize.
169+
:param:bitrate: Bitrate to set for the channel. Either this argument of both
170+
timing0 and timing1 must be set.
171+
:param:timing0: Raw BTR0 timing value to determine the bitrate. If this argument is set,
172+
timing1 must also be set and bitrate argument must be unset.
173+
:param:timing1: Raw BTR1 timing value to determine the bitrate. If this argument is set,
174+
timing0 must also be set and bitrate argument must be unset.
175+
:param:start: If True (default) then the channel is started after being initialized.
176+
If set to False, the channel will not be started until the start function is called
177+
manually.
124178
"""
125179
if bitrate is None and timing0 is None and timing1 is None:
126180
raise ValueError(
@@ -143,7 +197,7 @@ def init(self, channel, bitrate=None, timing0=None, timing1=None, start=True):
143197

144198
init_packet = protocol.InitCommand(
145199
command=protocol.COMMAND_INIT,
146-
acc_code=0x0,
200+
acc_code=0x1,
147201
acc_mask=0xFFFFFFFF,
148202
filter=0x1, # placeholder
149203
timing0=timing0,
@@ -156,20 +210,32 @@ def init(self, channel, bitrate=None, timing0=None, timing1=None, start=True):
156210
self.start(channel)
157211

158212
def stop(self, channel):
159-
"""Stop this channel. Data won't be sent or received on this channel until it is started again."""
213+
"""Stop this channel. CAN messages won't be sent or received on this channel until it is started again.
214+
215+
:param:channel: Channel (0 or 1) to stop. The channel must already be initialized.
216+
"""
160217
if not self._initialized[channel]:
161218
raise RuntimeError(f"Channel {channel} is not initialized.")
162219
self.send_command(channel, protocol.SimpleCommand(protocol.COMMAND_STOP))
163220
self._started[channel] = False
164221

165222
def start(self, channel):
166-
"""Start this channel."""
223+
"""Start this channel. This allows CAN messages to be sent and received. The hardware
224+
will buffer received messages until the receive() function is called.
225+
226+
:param:channel: Channel (0 or 1) to start. The channel must already be initialized.
227+
"""
167228
if not self._initialized[channel]:
168229
raise RuntimeError(f"Channel {channel} is not initialized.")
169230
self.send_command(channel, protocol.SimpleCommand(protocol.COMMAND_START))
170231
self._started[channel] = True
171232

172233
def receive(self, channel):
234+
"""Poll the hardware for received CAN messages and return them all as a list.
235+
236+
:param:channel: Channel (0 or 1) to poll. The channel must be started.
237+
:return: List of Message objects representing received CAN messages, in order.
238+
"""
173239
if not self._initialized[channel]:
174240
raise RuntimeError(f"Channel {channel} is not initialized.")
175241
if not self._started[channel]:
@@ -178,6 +244,8 @@ def receive(self, channel):
178244
channel, self.COMMAND_MESSAGE_STATUS, protocol.MessageStatusResponse
179245
)
180246

247+
print(status.unknown)
248+
181249
if status.rx_pending == 0:
182250
return []
183251

@@ -208,6 +276,20 @@ def receive(self, channel):
208276
return result
209277

210278
def send(self, channel, messages, flush_timeout=None):
279+
"""Send one or more CAN messages to the channel.
280+
281+
:param:channel: Channel (0 or 1) to send to. The channel must be started.
282+
:param:messages: Either a single Message object, or a list of
283+
Message objects to send.
284+
:param:flush_timeout: If set, don't return until TX buffer is flushed or timeout is
285+
reached.
286+
Setting this parameter causes the software to poll the device continuously
287+
for the buffer state. If None (default) then the function returns immediately,
288+
when some CAN messages may still be waiting to sent due to CAN bus arbitration.
289+
See flush_tx_buffer() function for details.
290+
:return: None if flush_timeout is None (default). Otherwise True if all messages sent
291+
(or failed), False if timeout reached.
292+
"""
211293
if not self._initialized[channel]:
212294
raise RuntimeError(f"Channel {channel} is not initialized.")
213295
if not self._started[channel]:
@@ -228,9 +310,14 @@ def send(self, channel, messages, flush_timeout=None):
228310
return self.flush_tx_buffer(channel, flush_timeout)
229311

230312
def get_can_status(self, channel):
231-
"""Return some internal CAN-related values. The actual meaning of these is currently unknown."""
313+
"""Return some internal CAN-related values. The actual meaning of these is currently unknown.
314+
315+
:return: Instance of the CANStatusResponse structure. Note the field names may not be accurate.
316+
"""
232317
if not self._initialized[channel]:
233-
logger.warning(f"Channel {channel} is not initialized, CAN status may be invalid.")
318+
logger.warning(
319+
f"Channel {channel} is not initialized, CAN status may be invalid."
320+
)
234321
return self.send_command(
235322
channel,
236323
protocol.SimpleCommand(protocol.COMMAND_CAN_STATUS),

canalystii/protocol.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
# Copyright (c) 2021 Angus Gratton
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
15
# This module containts all of the on-the-wire USB protocol format for the Canalyst-II
6+
27
from ctypes import (
38
c_bool,
49
c_byte,
@@ -86,17 +91,20 @@ class InitCommand(LittleEndianStructure):
8691
_pack_ = 1
8792
_fields_ = [
8893
("command", c_uint32), # COMMAND_INIT
89-
("acc_code", c_uint32), # Unknown (ACKs?), set to 0x0 for now
90-
("acc_mask", c_uint32), # Unknown (ACKs?), set to 0x0 for now
94+
("acc_code", c_uint32), # Unknown (ACK behvaiour?), set to 0x1 by CANPro(?)
95+
("acc_mask", c_uint32), # Similar, set to 0xFFFFFFFF by CANPro
9196
("unknown0", c_uint32), # 0x0 always? maybe related to filter?
9297
(
9398
"filter",
94-
c_uint32, # Set to 0x1 for "SingleFilter", 0x0 for "DualFilter" - function unknown
95-
),
99+
c_uint32,
100+
), # CANPro sets to 0x1 for "SingleFilter", 0x0 for "DualFilter" - meaning unknown
96101
("unknown1", c_uint32), # 0x0 always? maybe related to filter?
97102
("timing0", c_uint32), # BTR0
98103
("timing1", c_uint32), # BTR1
99-
("mode", c_uint32), # Unknown, set to 0x0 for now
104+
(
105+
"mode",
106+
c_uint32,
107+
), # Unknown, set to 0x0 for now. Setting 0x1 seems to cause device to crash(?)
100108
("unknown2", c_uint32), # Always 0x1 - function unknown
101109
("padding", c_uint32 * (0x10 - 0x0A)),
102110
]
@@ -112,7 +120,10 @@ class MessageStatusResponse(LittleEndianStructure):
112120
_fields_ = [
113121
("command", c_uint32),
114122
("rx_pending", c_uint32),
115-
("tx_pending", c_uint32),
123+
("tx_pending", c_uint16),
124+
# at one point this value was set to 0x1 which might have been an error condition (failed to send),
125+
# but might also have been a firmware bug (!). Have been unable to reproduce.
126+
("unknown", c_uint16),
116127
("padding", c_uint32 * (0x10 - 0x03)),
117128
]
118129

0 commit comments

Comments
 (0)