Metadata-Version: 2.5
Name: codrive
Version: 0.2.1
Summary: Simple API for Cosyne motor controllers using CAN FD
Project-URL: Homepage, https://github.com/cosyne-git/codrive-app
Project-URL: Repository, https://github.com/cosyne-git/codrive-app
Project-URL: Issues, https://github.com/cosyne-git/codrive-app/issues
Author-email: Cosyne <kontakt@cosyne.de>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: can,canfd,canopen,cia402,motion-control,motor,servo
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Manufacturing
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Requires-Python: >=3.10
Requires-Dist: matplotlib>=3.5
Requires-Dist: python-can-candle>=1.2
Requires-Dist: python-can>=4.2
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: ruff>=0.16; extra == 'dev'
Requires-Dist: twine>=6.1; extra == 'dev'
Description-Content-Type: text/markdown

# CoDrive

Simple Python API for Cosyne motor controllers over CAN FD.

The controller speaks `CoLink`, a simplified CiA 402 profile. `codrive` handles the bus,
the frame coding and the node addressing, so you work with drives instead of COB-IDs.

## Install

```
pip install codrive
```

## Connect to drive

```python
from codrive import Link

# Auto-connect to the shipped USB adapter
link = Link()
link.connect()

# Add drive with ID 1
drive = link.add_drive(node_id=1)

# Print state name
print(drive.state.name)

# Disconnect from bus
link.disconnect()
```

`connect()` opens the shipped adapter and configures it automatically. `add_drive` waits
for the drive to answer, so an absent node raises `TimeoutError`.

In one scope, a `with` block connects and disconnects for you:

```python
with Link() as link:
    drive = link.add_drive(1)
```

### Other CAN FD adapters

Pass any CAN FD capable `python-can` bus to `Link()`. Configure it for the controller's
bus yourself as SAE J2284-5: 500 kbit/s arbitration at an 80 % sample point, 5 Mbit/s
data at a 75 % sample point.

Using a PEAK PCAN-USB FD adapter:

```python
import can
from codrive import Link

timing = can.BitTimingFd(
    f_clock=80_000_000,
    nom_brp=1, nom_tseg1=127, nom_tseg2=32, nom_sjw=32,
    data_brp=1, data_tseg1=11, data_tseg2=4, data_sjw=4,
)
bus = can.Bus(interface="pcan", channel="PCAN_USBBUS1", timing=timing)

with Link(bus) as link:
    drive = link.add_drive(node_id=1)
    print(drive.state.name)

bus.shutdown()
```

A bus you open stays yours — the link leaves it open on disconnect, so close it yourself.

## Move

### Enable and set profile

Set the ramp limits once, enable, then command a mode. Each mode has its own call, so
the units are visible at the call site:

```python
drive.set_profile(velocity=800.0, acceleration=4000.0, torque_slope=5.0)
drive.enable()
```

`enable()` sets no mode, and `disable()`, `quick_stop()` and `reset_fault()` clear the
mode and setpoints. An enable therefore never starts motion by itself: after a stop or a
fault the drive comes back up idle. The `set_profile()` limits are kept, since a limit
cannot produce motion on its own.

### Profile position mode

```python
# Move to 1 rad using the velocity and acceleration from set_profile()
drive.move_to(1.0)
drive.wait_for_target()

# Move to 2 rad using the specified velocity and acceleration
drive.move_to(2.0, velocity=200.0, acceleration=1000.0)
drive.wait_for_target()

# Start position control without movement
current_position = drive.position
drive.move_to(current_position)
```

### Profile velocity mode

```python
# Run at 300 rad/s using the acceleration from set_profile()
drive.run_at(300.0)
drive.wait_for_target()

# Run at 50 rad/s using the specified acceleration
drive.run_at(50.0, acceleration=1000.0)
drive.wait_for_target()
```

### Profile torque mode

```python
# Set drive torque to 0.2 Nm using the torque slope from set_profile()
drive.apply_torque(0.2)
drive.wait_for_target()

# Set drive torque to 0.0 Nm using the specified torque slope
drive.apply_torque(0.0, slope=2.0)
drive.wait_for_target()
```

### Stop and disable

```python
# Standard method
drive.run_at(0.0)
drive.wait_for_target()
drive.disable()

# Also supported
drive.quick_stop()
drive.wait_for_target()
drive.disable()
```

`wait_for_target()` means the profile generator reached the commanded value, not that the
axis did — there is no following-error term, so it can return while the machine is still
turning. Add your own dwell before cutting torque.

**Important: Do not call `drive.disable()` while the drive is spinning!**

## Record and plot

Once the drive has answered, its latest status is available as

```python
print(drive.position)       # rad
print(drive.velocity)       # rad/s
print(drive.torque)         # Nm
print(drive.state.name)     # e.g. OPERATION_ENABLED
status = drive.status       # for all status information
```

The received status can be recorded using

```python
rec = link.record()

drive.move_to(1.0)
drive.wait_for_target()

rec.stop()
rec.plot()
```

Start and stop can sit in different cells or functions. Inside one scope, use a `with`
block:

```python
with link.record() as rec:
    drive.move_to(1.0)
    drive.wait_for_target()
```

Read the columns yourself for anything beyond `plot()`:

```python
import matplotlib.pyplot as plt

samples = rec.samples()
plt.plot(samples.time, samples.position)
```

## Faults

A fault ends the motion directly. `reset_fault()` only clears the fault if its cause is
gone; while it persists the drive stays in `FAULT` and the call raises `TimeoutError`.

```python
from codrive import DriveState

if drive.state is DriveState.FAULT:
    print(repr(drive.error))   # e.g. <ErrorCode.DC_LINK_UNDERVOLTAGE: 12832>
    drive.reset_fault()
```

`drive.error` returns `ErrorCode.NONE` when there is no fault, and `None` for a code
`codrive` does not name — `drive.status.error_code` still holds the raw number. Both are
falsy, so compare against `ErrorCode.NONE` rather than testing for truth.

## Multiple drives

All drives share one receive task, dispatched by node id:

```python
with Link() as link:
    axis1 = link.add_drive(1)
    axis2 = link.add_drive(2)

    for axis in (axis1, axis2):
        axis.set_profile(velocity=800.0, acceleration=4000.0)
        axis.enable()

    with link.record() as rec:
        axis1.move_to(1.57)         # both axes move at once
        axis2.move_to(-1.57)
        axis1.wait_for_target()
        axis2.wait_for_target()

    axis1.disable()
    axis2.disable()

rec.plot()       # every node, one colour each
rec.plot(axis1)  # one drive, by object or node id
```

## Status

Early, and the API may still change.

## License

Apache 2.0
