uc2canopen 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uc2canopen-0.1.4/.github/workflows/publish.yml +103 -0
- uc2canopen-0.1.4/.gitignore +3 -0
- uc2canopen-0.1.4/PKG-INFO +282 -0
- uc2canopen-0.1.4/README.md +264 -0
- uc2canopen-0.1.4/examples/can_sniffer.py +272 -0
- uc2canopen-0.1.4/examples/waveshare_bus.py +282 -0
- uc2canopen-0.1.4/pyproject.toml +33 -0
- uc2canopen-0.1.4/src/motor_demo.py +176 -0
- uc2canopen-0.1.4/src/uc2canopen/__init__.py +36 -0
- uc2canopen-0.1.4/src/uc2canopen/cli.py +241 -0
- uc2canopen-0.1.4/src/uc2canopen/client.py +492 -0
- uc2canopen-0.1.4/src/uc2canopen/od.py +271 -0
- uc2canopen-0.1.4/src/uc2canopen/sdo.py +295 -0
- uc2canopen-0.1.4/src/uc2canopen/waveshare_bus.py +330 -0
- uc2canopen-0.1.4/uv.lock +298 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Trigger on every push to main — the workflow bumps the patch version,
|
|
4
|
+
# commits the change, tags it, builds the wheel/sdist, publishes to PyPI,
|
|
5
|
+
# and creates a GitHub Release automatically.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
name: Bump version, build & publish
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
# Skip if this push was itself the version-bump commit to avoid
|
|
16
|
+
# an infinite trigger loop.
|
|
17
|
+
if: "!startsWith(github.event.head_commit.message, 'chore: bump version')"
|
|
18
|
+
|
|
19
|
+
permissions:
|
|
20
|
+
contents: write # push version-bump commit + tag
|
|
21
|
+
id-token: write # PyPI OIDC trusted publishing (no token needed)
|
|
22
|
+
|
|
23
|
+
steps:
|
|
24
|
+
# ── Checkout ────────────────────────────────────────────────────────────
|
|
25
|
+
- name: Checkout repository
|
|
26
|
+
uses: actions/checkout@v4
|
|
27
|
+
with:
|
|
28
|
+
fetch-depth: 0
|
|
29
|
+
# Use a PAT if you need the version-bump push to re-trigger other
|
|
30
|
+
# workflows; GITHUB_TOKEN is fine for just publishing to PyPI.
|
|
31
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
32
|
+
|
|
33
|
+
# ── Python + uv ─────────────────────────────────────────────────────────
|
|
34
|
+
- name: Set up Python
|
|
35
|
+
uses: actions/setup-python@v5
|
|
36
|
+
with:
|
|
37
|
+
python-version: "3.11"
|
|
38
|
+
|
|
39
|
+
- name: Install uv
|
|
40
|
+
uses: astral-sh/setup-uv@v5
|
|
41
|
+
|
|
42
|
+
# ── Bump patch version ──────────────────────────────────────────────────
|
|
43
|
+
- name: Bump patch version in pyproject.toml
|
|
44
|
+
id: bump
|
|
45
|
+
run: |
|
|
46
|
+
NEW_VERSION=$(python - << 'PYEOF'
|
|
47
|
+
import re, pathlib, sys
|
|
48
|
+
|
|
49
|
+
p = pathlib.Path("pyproject.toml")
|
|
50
|
+
text = p.read_text()
|
|
51
|
+
|
|
52
|
+
m = re.search(r'^(version\s*=\s*")(\d+)\.(\d+)\.(\d+)(")', text, re.MULTILINE)
|
|
53
|
+
if not m:
|
|
54
|
+
print("ERROR: version field not found in pyproject.toml", file=sys.stderr)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
major, minor, patch = int(m.group(2)), int(m.group(3)), int(m.group(4))
|
|
58
|
+
new_version = f"{major}.{minor}.{patch + 1}"
|
|
59
|
+
|
|
60
|
+
new_text = text[:m.start()] + m.group(1) + new_version + m.group(5) + text[m.end():]
|
|
61
|
+
p.write_text(new_text)
|
|
62
|
+
|
|
63
|
+
print(new_version)
|
|
64
|
+
PYEOF
|
|
65
|
+
)
|
|
66
|
+
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
|
67
|
+
echo "Bumped to $NEW_VERSION"
|
|
68
|
+
|
|
69
|
+
# ── Commit & tag ────────────────────────────────────────────────────────
|
|
70
|
+
- name: Commit version bump and create tag
|
|
71
|
+
run: |
|
|
72
|
+
git config user.name "github-actions[bot]"
|
|
73
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
74
|
+
git add pyproject.toml
|
|
75
|
+
git commit -m "chore: bump version to ${{ steps.bump.outputs.version }}"
|
|
76
|
+
git tag "v${{ steps.bump.outputs.version }}"
|
|
77
|
+
git push origin main --follow-tags
|
|
78
|
+
|
|
79
|
+
# ── Build ───────────────────────────────────────────────────────────────
|
|
80
|
+
- name: Build wheel and sdist
|
|
81
|
+
run: uv build
|
|
82
|
+
|
|
83
|
+
# ── Publish to PyPI ─────────────────────────────────────────────────────
|
|
84
|
+
# Option A (recommended): PyPI OIDC Trusted Publishing — no secret needed.
|
|
85
|
+
# Set up once at https://pypi.org/manage/project/uc2canopen/settings/publishing/
|
|
86
|
+
# Add a trusted publisher: owner=openUC2, repo=UC2-REST-CANOPEN,
|
|
87
|
+
# workflow=publish.yml, env=(leave blank).
|
|
88
|
+
#
|
|
89
|
+
# Option B: Classic API token — add PYPI_TOKEN as a repository secret and
|
|
90
|
+
# uncomment the env block below, then remove the trusted-publishing flag.
|
|
91
|
+
- name: Publish to PyPI
|
|
92
|
+
run: uv publish --trusted-publishing always
|
|
93
|
+
# env:
|
|
94
|
+
# UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
95
|
+
|
|
96
|
+
# ── GitHub Release ──────────────────────────────────────────────────────
|
|
97
|
+
- name: Create GitHub Release
|
|
98
|
+
uses: softprops/action-gh-release@v2
|
|
99
|
+
with:
|
|
100
|
+
tag_name: "v${{ steps.bump.outputs.version }}"
|
|
101
|
+
name: "v${{ steps.bump.outputs.version }}"
|
|
102
|
+
files: dist/*
|
|
103
|
+
generate_release_notes: true
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uc2canopen
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Python CANopen master for openUC2 microscope hardware — control motors, lasers, LEDs, galvos directly over CAN bus (SocketCAN MCP2515 HAT or Waveshare USB-CAN-A adapter)
|
|
5
|
+
Project-URL: Homepage, https://github.com/openUC2/uc2-esp32
|
|
6
|
+
Project-URL: Documentation, https://openuc2.github.io
|
|
7
|
+
Author-email: openUC2 GmbH <info@openuc2.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pyserial>=3.5
|
|
11
|
+
Requires-Dist: python-can>=4.0
|
|
12
|
+
Provides-Extra: canopen
|
|
13
|
+
Requires-Dist: canopen>=2.0; extra == 'canopen'
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# uc2canopen — Python CANopen master for openUC2
|
|
20
|
+
|
|
21
|
+
Control UC2 microscope motors, lasers, LEDs, and galvos directly over CAN bus
|
|
22
|
+
from Python — no ESP32 master required, no JSON-over-serial, just CANopen
|
|
23
|
+
SDO/PDO.
|
|
24
|
+
|
|
25
|
+
Two transports are supported:
|
|
26
|
+
|
|
27
|
+
- **MCP2515 SPI HAT (SocketCAN)** — recommended on a Raspberry Pi. The HAT
|
|
28
|
+
enumerates as a native `can0` interface. *This is the default.*
|
|
29
|
+
- **[Waveshare USB-CAN-A](https://www.waveshare.com/wiki/USB-CAN-A)** — a USB
|
|
30
|
+
dongle, handy on a laptop.
|
|
31
|
+
|
|
32
|
+
Same API shape as [UC2-REST](https://github.com/openUC2/UC2-REST), different
|
|
33
|
+
transport: CANopen instead of JSON-over-serial.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 1. Raspberry Pi + MCP2515 HAT setup (one-time)
|
|
38
|
+
|
|
39
|
+
> Skip this section if you use the Waveshare USB adapter — see
|
|
40
|
+
> [Waveshare USB-CAN-A](#waveshare-usb-can-a-alternative) at the bottom.
|
|
41
|
+
|
|
42
|
+
The HAT is an **MCP2515** CAN controller on **SPI0** (CS = `SPI0_CE0`/GPIO8,
|
|
43
|
+
INT = GPIO12) with an **SN65HVD230** 3.3 V transceiver and a **12 MHz** crystal.
|
|
44
|
+
|
|
45
|
+
### 1a. Enable SPI and load the MCP2515 driver
|
|
46
|
+
|
|
47
|
+
Edit `/boot/firmware/config.txt` and add:
|
|
48
|
+
|
|
49
|
+
```ini
|
|
50
|
+
dtparam=spi=on
|
|
51
|
+
dtoverlay=mcp2515-can0,oscillator=12000000,interrupt=12,spimaxfrequency=10000000
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `oscillator=12000000` **must match the crystal on the board** (12 MHz here).
|
|
55
|
+
A wrong value = `can0` comes up but never sees a frame.
|
|
56
|
+
- `interrupt=12` is the INT line wiring (GPIO12). If SPI is flaky, drop
|
|
57
|
+
`spimaxfrequency` to `2000000`.
|
|
58
|
+
|
|
59
|
+
Reboot, then confirm the driver bound:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
sudo reboot
|
|
63
|
+
# after it's back:
|
|
64
|
+
dmesg | grep -iE "mcp251|can0"
|
|
65
|
+
# → mcp251x spi0.0 can0: MCP2515 successfully initialized.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 1b. Bring the interface up @ 500 kbit/s
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
sudo ip link set can0 up type can bitrate 500000 restart-ms 100
|
|
72
|
+
ip -details link show can0 # state should be ERROR-ACTIVE
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
500 kbit/s is the UC2 firmware bitrate — it **must match** your nodes.
|
|
76
|
+
`restart-ms 100` auto-recovers from bus-off while debugging.
|
|
77
|
+
|
|
78
|
+
### 1c. Sanity-check the bus (optional but recommended)
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
sudo apt install -y can-utils
|
|
82
|
+
candump can0
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
You should see live traffic — CANopen heartbeats at `0x700 + node-ID`
|
|
86
|
+
(e.g. `0x70B` = node 11) and motor TPDOs at `0x180 + node-ID`. If frames
|
|
87
|
+
scroll, the whole chain works. Send an NMT "start all":
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
cansend can0 000#0100
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 1d. Make `can0` come up at boot (optional)
|
|
94
|
+
|
|
95
|
+
Create `/etc/systemd/system/can0.service`:
|
|
96
|
+
|
|
97
|
+
```ini
|
|
98
|
+
[Unit]
|
|
99
|
+
Description=Bring up can0 (MCP2515)
|
|
100
|
+
After=sys-subsystem-net-devices-can0.device
|
|
101
|
+
BindsTo=sys-subsystem-net-devices-can0.device
|
|
102
|
+
|
|
103
|
+
[Service]
|
|
104
|
+
Type=oneshot
|
|
105
|
+
RemainAfterExit=yes
|
|
106
|
+
ExecStart=/sbin/ip link set can0 up type can bitrate 500000 restart-ms 100
|
|
107
|
+
ExecStop=/sbin/ip link set can0 down
|
|
108
|
+
|
|
109
|
+
[Install]
|
|
110
|
+
WantedBy=multi-user.target
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
sudo systemctl enable --now can0.service
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 2. Install the Python package
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# with uv (recommended)
|
|
123
|
+
uv pip install -e .
|
|
124
|
+
|
|
125
|
+
# or with pip
|
|
126
|
+
pip install -e .
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`python-can`'s SocketCAN backend is built in on Linux — nothing extra to
|
|
130
|
+
install for the HAT. (`pyserial` is pulled in too, only needed for the
|
|
131
|
+
Waveshare adapter.)
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install uc2canopen
|
|
135
|
+
```
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 3. Run the demo
|
|
139
|
+
|
|
140
|
+
With `can0` up (step 1b), from the repo root:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python src/motor_demo.py --motor-node 11
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
This scans for nodes, jogs motor node 11 back and forth, blinks the laser/LED
|
|
147
|
+
board, and prints node status. Useful flags: `--skip-laser --skip-led`,
|
|
148
|
+
`--steps`, `--speed`, `--channel can1`.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 4. Python API
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from uc2canopen import UC2Client, NODE
|
|
156
|
+
|
|
157
|
+
# Defaults to SocketCAN can0 (the MCP2515 HAT) — bring it up first with
|
|
158
|
+
# sudo ip link set can0 up type can bitrate 500000
|
|
159
|
+
uc2 = UC2Client()
|
|
160
|
+
# Other channel: UC2Client(channel="can1")
|
|
161
|
+
# Waveshare USB adapter: UC2Client(port="/dev/ttyUSB0")
|
|
162
|
+
|
|
163
|
+
# Move motor X axis 1000 steps
|
|
164
|
+
uc2.motor.move(axis=0, position=1000, speed=20000, node_id=NODE.MOT_X)
|
|
165
|
+
uc2.motor.wait_for_idle(axis=0, node_id=NODE.MOT_X)
|
|
166
|
+
print(f"Motor at {uc2.motor.get_position(axis=0, node_id=NODE.MOT_X)} steps")
|
|
167
|
+
|
|
168
|
+
uc2.laser.set_value(channel=0, pwm=512, node_id=NODE.LASER_0) # laser 50%
|
|
169
|
+
uc2.led.fill(r=255, g=0, b=0, node_id=NODE.LED_0) # LEDs red
|
|
170
|
+
print(f"Uptime: {uc2.state.get_uptime(NODE.MOT_X)}s")
|
|
171
|
+
|
|
172
|
+
uc2.close()
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## 5. CLI
|
|
176
|
+
|
|
177
|
+
The `uc2can` command also defaults to SocketCAN `can0`:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
uc2can scan # find nodes on the bus
|
|
181
|
+
uc2can move --node 11 --pos 1000 --speed 20000 --wait
|
|
182
|
+
uc2can laser --node 20 --ch 0 --pwm 512
|
|
183
|
+
uc2can led --node 20 --r 255 --g 0 --b_val 0
|
|
184
|
+
uc2can status --node 11
|
|
185
|
+
uc2can sniff # dump raw CAN frames
|
|
186
|
+
uc2can reboot --node 11
|
|
187
|
+
|
|
188
|
+
# pick a transport/channel (global flags, before the subcommand):
|
|
189
|
+
uc2can --channel can1 scan
|
|
190
|
+
uc2can --interface waveshare --port /dev/ttyUSB0 scan
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Node-ID assignments
|
|
196
|
+
|
|
197
|
+
| Role | Node-ID | Python constant |
|
|
198
|
+
|------|---------|-----------------|
|
|
199
|
+
| Master (this script) | 1 | `NODE.MASTER` |
|
|
200
|
+
| Motor X | 11 | `NODE.MOT_X` |
|
|
201
|
+
| Motor Y | 12 | `NODE.MOT_Y` |
|
|
202
|
+
| Motor Z | 13 | `NODE.MOT_Z` |
|
|
203
|
+
| Motor A | 14 | `NODE.MOT_A` |
|
|
204
|
+
| LED / combined illum. board | 20 | `NODE.LED` (alias: `LED_0`, `LASER_0`) |
|
|
205
|
+
| Laser (separate board) | 21 | `NODE.LASER` (alias: `LASER_1`) |
|
|
206
|
+
| Joystick | 22 | `NODE.JOYSTICK` |
|
|
207
|
+
| Galvo | 30 | `NODE.GALVO` |
|
|
208
|
+
| Galvo 2 | 31 | `NODE.GALVO_2` |
|
|
209
|
+
| Encoder | 40 | `NODE.ENCODER` |
|
|
210
|
+
| PID | 50 | `NODE.PID` |
|
|
211
|
+
|
|
212
|
+
## Architecture
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
Your Python script
|
|
216
|
+
│
|
|
217
|
+
▼
|
|
218
|
+
UC2Client
|
|
219
|
+
├── motor → Motor() SDO writes to 0x2000-0x200B
|
|
220
|
+
├── laser → Laser() SDO writes to 0x2100
|
|
221
|
+
├── led → Led() SDO writes to 0x2200
|
|
222
|
+
├── state → State() SDO reads from 0x2500+
|
|
223
|
+
│
|
|
224
|
+
├── SdoClient raw SDO upload/download over python-can
|
|
225
|
+
├── TpdoListener background thread for motor state (TPDO1)
|
|
226
|
+
│
|
|
227
|
+
└── python-can BusABC ── SocketCAN can0 (MCP2515 HAT) ← default
|
|
228
|
+
└─ WaveshareBus (USB-CAN-A serial)
|
|
229
|
+
│
|
|
230
|
+
▼ CAN bus @ 500 kbit/s
|
|
231
|
+
│
|
|
232
|
+
┌───┴───┬───────┬────────┐
|
|
233
|
+
│ │ │ │
|
|
234
|
+
Slave Slave Slave Slave
|
|
235
|
+
node11 node12 node13 node20
|
|
236
|
+
(mot X) (mot Y) (mot Z) (illum)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The whole SDO/PDO stack is transport-agnostic (it talks to a python-can
|
|
240
|
+
`BusABC`), so the only difference between transports is which bus object
|
|
241
|
+
`UC2Client` builds.
|
|
242
|
+
|
|
243
|
+
## OD index reference
|
|
244
|
+
|
|
245
|
+
All indices are in `uc2canopen.od.OD` and match the firmware's
|
|
246
|
+
`UC2_OD_Indices.h` (generated from `uc2_canopen_registry.yaml`).
|
|
247
|
+
|
|
248
|
+
## Troubleshooting
|
|
249
|
+
|
|
250
|
+
| Symptom | Most likely cause |
|
|
251
|
+
|---|---|
|
|
252
|
+
| No `can0`; `dmesg` empty/error | SPI not enabled, wrong `interrupt=`, or SPI wiring |
|
|
253
|
+
| `can0` up but `candump` empty | Wrong `oscillator=` (crystal), bitrate ≠ 500 k, or missing 120 Ω termination |
|
|
254
|
+
| `RuntimeError: No Waveshare ... adapter found` | You asked for the Waveshare transport (`--port`/`interface="waveshare"`) but none is attached; for the HAT just use the default |
|
|
255
|
+
| `Failed to open CAN bus` on the HAT | `can0` isn't up — run `sudo ip link set can0 up type can bitrate 500000 restart-ms 100` |
|
|
256
|
+
| Error frames / bus-off | Bitrate mismatch, CANH↔CANL swapped, or no common GND |
|
|
257
|
+
|
|
258
|
+
## Waveshare USB-CAN-A (alternative)
|
|
259
|
+
|
|
260
|
+
No driver setup needed — plug it in and select it explicitly:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
python src/motor_demo.py --interface waveshare --port /dev/ttyUSB0 --motor-node 11
|
|
264
|
+
uc2can --interface waveshare scan # auto-detects the port
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
uc2 = UC2Client(port="/dev/ttyUSB0") # a port implies the Waveshare transport
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Requirements
|
|
272
|
+
|
|
273
|
+
- Python ≥ 3.10
|
|
274
|
+
- `python-can` ≥ 4.0 (SocketCAN backend built in on Linux)
|
|
275
|
+
- `pyserial` (only for the Waveshare adapter)
|
|
276
|
+
- Hardware: an MCP2515 SPI HAT **or** a Waveshare USB-CAN-A adapter, plus UC2 slave(s) on the bus
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
MIT — same as the UC2-ESP32 firmware.
|
|
281
|
+
</content>
|
|
282
|
+
</invoke>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# uc2canopen — Python CANopen master for openUC2
|
|
2
|
+
|
|
3
|
+
Control UC2 microscope motors, lasers, LEDs, and galvos directly over CAN bus
|
|
4
|
+
from Python — no ESP32 master required, no JSON-over-serial, just CANopen
|
|
5
|
+
SDO/PDO.
|
|
6
|
+
|
|
7
|
+
Two transports are supported:
|
|
8
|
+
|
|
9
|
+
- **MCP2515 SPI HAT (SocketCAN)** — recommended on a Raspberry Pi. The HAT
|
|
10
|
+
enumerates as a native `can0` interface. *This is the default.*
|
|
11
|
+
- **[Waveshare USB-CAN-A](https://www.waveshare.com/wiki/USB-CAN-A)** — a USB
|
|
12
|
+
dongle, handy on a laptop.
|
|
13
|
+
|
|
14
|
+
Same API shape as [UC2-REST](https://github.com/openUC2/UC2-REST), different
|
|
15
|
+
transport: CANopen instead of JSON-over-serial.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. Raspberry Pi + MCP2515 HAT setup (one-time)
|
|
20
|
+
|
|
21
|
+
> Skip this section if you use the Waveshare USB adapter — see
|
|
22
|
+
> [Waveshare USB-CAN-A](#waveshare-usb-can-a-alternative) at the bottom.
|
|
23
|
+
|
|
24
|
+
The HAT is an **MCP2515** CAN controller on **SPI0** (CS = `SPI0_CE0`/GPIO8,
|
|
25
|
+
INT = GPIO12) with an **SN65HVD230** 3.3 V transceiver and a **12 MHz** crystal.
|
|
26
|
+
|
|
27
|
+
### 1a. Enable SPI and load the MCP2515 driver
|
|
28
|
+
|
|
29
|
+
Edit `/boot/firmware/config.txt` and add:
|
|
30
|
+
|
|
31
|
+
```ini
|
|
32
|
+
dtparam=spi=on
|
|
33
|
+
dtoverlay=mcp2515-can0,oscillator=12000000,interrupt=12,spimaxfrequency=10000000
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `oscillator=12000000` **must match the crystal on the board** (12 MHz here).
|
|
37
|
+
A wrong value = `can0` comes up but never sees a frame.
|
|
38
|
+
- `interrupt=12` is the INT line wiring (GPIO12). If SPI is flaky, drop
|
|
39
|
+
`spimaxfrequency` to `2000000`.
|
|
40
|
+
|
|
41
|
+
Reboot, then confirm the driver bound:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
sudo reboot
|
|
45
|
+
# after it's back:
|
|
46
|
+
dmesg | grep -iE "mcp251|can0"
|
|
47
|
+
# → mcp251x spi0.0 can0: MCP2515 successfully initialized.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 1b. Bring the interface up @ 500 kbit/s
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
sudo ip link set can0 up type can bitrate 500000 restart-ms 100
|
|
54
|
+
ip -details link show can0 # state should be ERROR-ACTIVE
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
500 kbit/s is the UC2 firmware bitrate — it **must match** your nodes.
|
|
58
|
+
`restart-ms 100` auto-recovers from bus-off while debugging.
|
|
59
|
+
|
|
60
|
+
### 1c. Sanity-check the bus (optional but recommended)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
sudo apt install -y can-utils
|
|
64
|
+
candump can0
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
You should see live traffic — CANopen heartbeats at `0x700 + node-ID`
|
|
68
|
+
(e.g. `0x70B` = node 11) and motor TPDOs at `0x180 + node-ID`. If frames
|
|
69
|
+
scroll, the whole chain works. Send an NMT "start all":
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
cansend can0 000#0100
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 1d. Make `can0` come up at boot (optional)
|
|
76
|
+
|
|
77
|
+
Create `/etc/systemd/system/can0.service`:
|
|
78
|
+
|
|
79
|
+
```ini
|
|
80
|
+
[Unit]
|
|
81
|
+
Description=Bring up can0 (MCP2515)
|
|
82
|
+
After=sys-subsystem-net-devices-can0.device
|
|
83
|
+
BindsTo=sys-subsystem-net-devices-can0.device
|
|
84
|
+
|
|
85
|
+
[Service]
|
|
86
|
+
Type=oneshot
|
|
87
|
+
RemainAfterExit=yes
|
|
88
|
+
ExecStart=/sbin/ip link set can0 up type can bitrate 500000 restart-ms 100
|
|
89
|
+
ExecStop=/sbin/ip link set can0 down
|
|
90
|
+
|
|
91
|
+
[Install]
|
|
92
|
+
WantedBy=multi-user.target
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
sudo systemctl enable --now can0.service
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 2. Install the Python package
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# with uv (recommended)
|
|
105
|
+
uv pip install -e .
|
|
106
|
+
|
|
107
|
+
# or with pip
|
|
108
|
+
pip install -e .
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`python-can`'s SocketCAN backend is built in on Linux — nothing extra to
|
|
112
|
+
install for the HAT. (`pyserial` is pulled in too, only needed for the
|
|
113
|
+
Waveshare adapter.)
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pip install uc2canopen
|
|
117
|
+
```
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 3. Run the demo
|
|
121
|
+
|
|
122
|
+
With `can0` up (step 1b), from the repo root:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python src/motor_demo.py --motor-node 11
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
This scans for nodes, jogs motor node 11 back and forth, blinks the laser/LED
|
|
129
|
+
board, and prints node status. Useful flags: `--skip-laser --skip-led`,
|
|
130
|
+
`--steps`, `--speed`, `--channel can1`.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 4. Python API
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from uc2canopen import UC2Client, NODE
|
|
138
|
+
|
|
139
|
+
# Defaults to SocketCAN can0 (the MCP2515 HAT) — bring it up first with
|
|
140
|
+
# sudo ip link set can0 up type can bitrate 500000
|
|
141
|
+
uc2 = UC2Client()
|
|
142
|
+
# Other channel: UC2Client(channel="can1")
|
|
143
|
+
# Waveshare USB adapter: UC2Client(port="/dev/ttyUSB0")
|
|
144
|
+
|
|
145
|
+
# Move motor X axis 1000 steps
|
|
146
|
+
uc2.motor.move(axis=0, position=1000, speed=20000, node_id=NODE.MOT_X)
|
|
147
|
+
uc2.motor.wait_for_idle(axis=0, node_id=NODE.MOT_X)
|
|
148
|
+
print(f"Motor at {uc2.motor.get_position(axis=0, node_id=NODE.MOT_X)} steps")
|
|
149
|
+
|
|
150
|
+
uc2.laser.set_value(channel=0, pwm=512, node_id=NODE.LASER_0) # laser 50%
|
|
151
|
+
uc2.led.fill(r=255, g=0, b=0, node_id=NODE.LED_0) # LEDs red
|
|
152
|
+
print(f"Uptime: {uc2.state.get_uptime(NODE.MOT_X)}s")
|
|
153
|
+
|
|
154
|
+
uc2.close()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 5. CLI
|
|
158
|
+
|
|
159
|
+
The `uc2can` command also defaults to SocketCAN `can0`:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
uc2can scan # find nodes on the bus
|
|
163
|
+
uc2can move --node 11 --pos 1000 --speed 20000 --wait
|
|
164
|
+
uc2can laser --node 20 --ch 0 --pwm 512
|
|
165
|
+
uc2can led --node 20 --r 255 --g 0 --b_val 0
|
|
166
|
+
uc2can status --node 11
|
|
167
|
+
uc2can sniff # dump raw CAN frames
|
|
168
|
+
uc2can reboot --node 11
|
|
169
|
+
|
|
170
|
+
# pick a transport/channel (global flags, before the subcommand):
|
|
171
|
+
uc2can --channel can1 scan
|
|
172
|
+
uc2can --interface waveshare --port /dev/ttyUSB0 scan
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Node-ID assignments
|
|
178
|
+
|
|
179
|
+
| Role | Node-ID | Python constant |
|
|
180
|
+
|------|---------|-----------------|
|
|
181
|
+
| Master (this script) | 1 | `NODE.MASTER` |
|
|
182
|
+
| Motor X | 11 | `NODE.MOT_X` |
|
|
183
|
+
| Motor Y | 12 | `NODE.MOT_Y` |
|
|
184
|
+
| Motor Z | 13 | `NODE.MOT_Z` |
|
|
185
|
+
| Motor A | 14 | `NODE.MOT_A` |
|
|
186
|
+
| LED / combined illum. board | 20 | `NODE.LED` (alias: `LED_0`, `LASER_0`) |
|
|
187
|
+
| Laser (separate board) | 21 | `NODE.LASER` (alias: `LASER_1`) |
|
|
188
|
+
| Joystick | 22 | `NODE.JOYSTICK` |
|
|
189
|
+
| Galvo | 30 | `NODE.GALVO` |
|
|
190
|
+
| Galvo 2 | 31 | `NODE.GALVO_2` |
|
|
191
|
+
| Encoder | 40 | `NODE.ENCODER` |
|
|
192
|
+
| PID | 50 | `NODE.PID` |
|
|
193
|
+
|
|
194
|
+
## Architecture
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
Your Python script
|
|
198
|
+
│
|
|
199
|
+
▼
|
|
200
|
+
UC2Client
|
|
201
|
+
├── motor → Motor() SDO writes to 0x2000-0x200B
|
|
202
|
+
├── laser → Laser() SDO writes to 0x2100
|
|
203
|
+
├── led → Led() SDO writes to 0x2200
|
|
204
|
+
├── state → State() SDO reads from 0x2500+
|
|
205
|
+
│
|
|
206
|
+
├── SdoClient raw SDO upload/download over python-can
|
|
207
|
+
├── TpdoListener background thread for motor state (TPDO1)
|
|
208
|
+
│
|
|
209
|
+
└── python-can BusABC ── SocketCAN can0 (MCP2515 HAT) ← default
|
|
210
|
+
└─ WaveshareBus (USB-CAN-A serial)
|
|
211
|
+
│
|
|
212
|
+
▼ CAN bus @ 500 kbit/s
|
|
213
|
+
│
|
|
214
|
+
┌───┴───┬───────┬────────┐
|
|
215
|
+
│ │ │ │
|
|
216
|
+
Slave Slave Slave Slave
|
|
217
|
+
node11 node12 node13 node20
|
|
218
|
+
(mot X) (mot Y) (mot Z) (illum)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The whole SDO/PDO stack is transport-agnostic (it talks to a python-can
|
|
222
|
+
`BusABC`), so the only difference between transports is which bus object
|
|
223
|
+
`UC2Client` builds.
|
|
224
|
+
|
|
225
|
+
## OD index reference
|
|
226
|
+
|
|
227
|
+
All indices are in `uc2canopen.od.OD` and match the firmware's
|
|
228
|
+
`UC2_OD_Indices.h` (generated from `uc2_canopen_registry.yaml`).
|
|
229
|
+
|
|
230
|
+
## Troubleshooting
|
|
231
|
+
|
|
232
|
+
| Symptom | Most likely cause |
|
|
233
|
+
|---|---|
|
|
234
|
+
| No `can0`; `dmesg` empty/error | SPI not enabled, wrong `interrupt=`, or SPI wiring |
|
|
235
|
+
| `can0` up but `candump` empty | Wrong `oscillator=` (crystal), bitrate ≠ 500 k, or missing 120 Ω termination |
|
|
236
|
+
| `RuntimeError: No Waveshare ... adapter found` | You asked for the Waveshare transport (`--port`/`interface="waveshare"`) but none is attached; for the HAT just use the default |
|
|
237
|
+
| `Failed to open CAN bus` on the HAT | `can0` isn't up — run `sudo ip link set can0 up type can bitrate 500000 restart-ms 100` |
|
|
238
|
+
| Error frames / bus-off | Bitrate mismatch, CANH↔CANL swapped, or no common GND |
|
|
239
|
+
|
|
240
|
+
## Waveshare USB-CAN-A (alternative)
|
|
241
|
+
|
|
242
|
+
No driver setup needed — plug it in and select it explicitly:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
python src/motor_demo.py --interface waveshare --port /dev/ttyUSB0 --motor-node 11
|
|
246
|
+
uc2can --interface waveshare scan # auto-detects the port
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
uc2 = UC2Client(port="/dev/ttyUSB0") # a port implies the Waveshare transport
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Requirements
|
|
254
|
+
|
|
255
|
+
- Python ≥ 3.10
|
|
256
|
+
- `python-can` ≥ 4.0 (SocketCAN backend built in on Linux)
|
|
257
|
+
- `pyserial` (only for the Waveshare adapter)
|
|
258
|
+
- Hardware: an MCP2515 SPI HAT **or** a Waveshare USB-CAN-A adapter, plus UC2 slave(s) on the bus
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT — same as the UC2-ESP32 firmware.
|
|
263
|
+
</content>
|
|
264
|
+
</invoke>
|