Compare commits

...

2 commits

Author SHA1 Message Date
d6522b5d4a Add README.md 2026-04-30 10:05:06 +02:00
lucavanstraaten
1a2ce7e7c0 LED: show encoder position as hue after each read
- Add setPositionColor(): maps SSI value to HSV hue (0=red, 120=green, 240=blue)
- LED takes the position hue as its resting colour between reads
- Blue during active read unchanged
- All error paths now flash red before printing ERR
2026-04-28 21:18:35 +02:00
2 changed files with 312 additions and 2 deletions

285
README.md Normal file
View file

@ -0,0 +1,285 @@
# ssi-pico-bridge
A DIY reader that connects **SSI (Synchronous Serial Interface)** absolute
encoders to a PC over USB, using a Raspberry Pi Pico (RP2040) and two cheap
MAX485 modules.
Built because off-the-shelf SSI interfaces are expensive and most general-purpose
serial devices can't talk SSI: SSI is a *synchronous*, clocked protocol with
two differential pairs (CLOCK and DATA). This project bridges the gap with
about €5 of parts and exposes the encoder's position as plain text over a USB
serial port — easy to script from Python, log to disk, or feed into any other
tool.
## How it works
The Pico acts as the **SSI master**: it drives the CLOCK line, samples DATA on
each falling edge, and assembles the position word. Two MAX485 modules act as
RS-422 line drivers/receivers — one transmits the clock to the encoder, the
other receives the data from it. The Pico exposes a simple text protocol over
USB-CDC serial that lets a host request a read of N bits at a chosen clock
speed.
```
┌──────────────────────────────────────────────────┐
│ │
┌──────────┐ │ ┌───────────┐ CLK+/CLK- ┌─────────────┐ │
│ │ CLK ─┤ MAX485 #1 ├──────────────────┤ │ │
│ RP2040 │ │ │ (TX only) │ (twisted pair) │ │ │
│ │ │ └───────────┘ │ │ │
│ Pico │ │ │ SSI Encoder │ │
│ │ │ ┌───────────┐ DATA+/DATA- │ │ │
│ │ DATA─┤ MAX485 #2 ├──────────────────┤ │ │
│ │ │ │ (RX only) │ (twisted pair) │ │ │
└──────────┘ │ └───────────┘ └─────────────┘ │
│ └──────────────────────────────────────────────────┘
│ USB
│ "READ 25 5\n" →
│ ← "OK bits=25 hex=0x1A2B3C4 ..."
PC
```
## Features
- Reads any standard SSI encoder, 132 bits per frame
- Configurable clock speed (1 µs to 10 ms half-period)
- PIO-based timing with sub-100 ns jitter
- Works with both Gray-coded and binary encoders (decoding done host-side
or in firmware — see `examples/`)
- USB-CDC serial — appears as a plain COM port on Windows / `/dev/ttyACM*` on
Linux / `/dev/cu.usbmodem*` on macOS
- WS2812 status LED for at-a-glance health
- Simple ASCII protocol — easy to script from Python, Node, shell, etc.
## Hardware
### Bill of materials
| Qty | Part | Where to buy | Approx. price |
|-----|------|--------------|---------------|
| 1 | Raspberry Pi Pico / RP2040-Zero / RP2040 dev board | TinyTronics, Kiwi Electronics, Opencircuit | €5 |
| 2 | MAX485 module (HW-097 or equivalent, blue PCB) | Opencircuit, AliExpress | €1.60 each |
| 1 | Breadboard + jumper wires | any electronics shop | €5 |
| 1 | SSI encoder | varies | varies |
| - | Twisted-pair cable for the differential lines (Cat5e is fine) | | |
The MAX485 modules typically have a 120 Ω termination resistor already on
board. This is correct for the DATA receiver and harmless on the CLOCK driver
over short cables.
### Pin assignments
The reference firmware uses the following GPIO assignments on the RP2040.
#### CLOCK module (MAX485 #1, transmit only)
| MAX485 pin | Connects to | Notes |
|------------|-------------|-------|
| VCC | Pico VBUS (5 V) | MAX485 needs 5 V; RP2040 GPIOs are 5 V tolerant |
| GND | Pico GND | shared ground is required |
| DI | Pico GP0 | clock output, driven by PIO |
| DE | Pico GP1 | tied HIGH (driver always on) |
| RE | Pico GP2 | tied HIGH (receiver disabled) |
| RO | Pico GP3 | unused |
| A | Encoder CLK+ | twisted pair to encoder |
| B | Encoder CLK | twisted pair to encoder |
#### DATA module (MAX485 #2, receive only)
| MAX485 pin | Connects to | Notes |
|------------|-------------|-------|
| VCC | Pico VBUS (5 V) | |
| GND | Pico GND | |
| DI | Pico GP26 | unused, parked LOW |
| DE | Pico GP27 | tied LOW (driver disabled, A/B in high-Z) |
| RE | Pico GP28 | tied LOW (receiver always on) |
| RO | Pico GP29 | data input, sampled by PIO |
| A | Encoder DATA+ | twisted pair to encoder |
| B | Encoder DATA | twisted pair to encoder |
#### Other connections
| Pin | Function |
|-----|----------|
| GP16 | WS2812 status LED (on RP2040-Zero this is the on-board NeoPixel) |
| Encoder GND | **Must** be tied to Pico GND |
| Encoder V+ | Powered separately according to its datasheet (often 5 V or 1030 V) |
### A note on differential pair polarity
If you wire the encoder's CLK+/CLK to A/B by name and get garbage data,
swap them — RS-422/485 polarity conventions vary between manufacturers and
some label A and B opposite to what the MAX485 expects. The DATA pair can
also be swapped if needed; that simply inverts the bits, which is easy to
detect in the captured value.
## Software
### Build environment
The firmware is written in C++ for the [Arduino-Pico core][arduino-pico] and
built with [PlatformIO]. PIO assembly is compiled at build time by `pioasm`,
which ships with the framework.
[arduino-pico]: https://github.com/earlephilhower/arduino-pico
[PlatformIO]: https://platformio.org
### Building and flashing
1. Clone this repository.
2. Open the folder in VS Code with the PlatformIO extension installed,
or run `pio run` from the command line.
3. Hold BOOT on the RP2040 and plug in USB for the first flash.
4. Run `pio run -t upload`.
After the first flash, subsequent uploads use `picotool` over USB and don't
need the BOOT button — PlatformIO resets the board into bootloader mode
automatically.
### `platformio.ini`
```ini
[env:rp2040zero]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = waveshare_rp2040_zero
framework = arduino
board_build.core = earlephilhower
upload_protocol = picotool
monitor_speed = 115200
lib_deps =
adafruit/Adafruit NeoPixel @ ^1.12.0
```
### Project layout
```
ssi-pico-bridge/
├── platformio.ini
├── README.md
└── src/
├── main.cpp # entry point, serial protocol handler
└── ssi.pio # PIO state machine for SSI master timing
```
## Connecting from a PC
After flashing, plug the Pico into your PC over USB. It enumerates as a
USB-CDC serial device:
- **Windows** — appears as a new `COM` port (e.g. `COM5`). Check Device Manager.
- **Linux**`/dev/ttyACM0` (or higher number if you have other ACM devices).
- **macOS**`/dev/cu.usbmodemXXXX`.
Open it at **115200 baud, 8N1** with any serial terminal (PuTTY, screen, minicom,
the Arduino IDE's Serial Monitor, PlatformIO's monitor, etc.) and start sending
commands.
## Serial protocol
### Commands
#### `READ <bits> <half_us>`
Performs one SSI read.
- `bits` — number of data bits to clock out, 132
- `half_us` — duration of one CLK half-period in microseconds, 110000
Example:
```
> READ 25 5
< OK bits=25 half_us=5 hex=0x1A2B3C4 dec=27439556 took=292us
```
A successful read replies with `OK` followed by the parameters echoed back,
the captured value as both hex and decimal, and the wall-clock time the read
took. Errors reply with `ERR <message>`.
### Notes for typical encoders
| Encoder type | Bits | Suggested half_us |
|--------------|------|-------------------|
| 13-bit single-turn | 13 | 5 |
| 25-bit multi-turn (12 turns + 13 single) | 25 | 5 |
| 24-bit BiSS-C-compatible | 24 + CRC | check datasheet |
Many encoders are Gray-coded; the host or firmware must convert to binary
before the value represents a usable position. See `examples/gray_to_binary.py`.
### Example: Python host
```python
import serial
ssi = serial.Serial('/dev/ttyACM0', 115200, timeout=1)
def read_position(bits=25, half_us=5):
ssi.write(f"READ {bits} {half_us}\n".encode())
line = ssi.readline().decode().strip()
if not line.startswith("OK"):
raise RuntimeError(line)
parts = dict(p.split('=') for p in line.split()[1:])
raw = int(parts['hex'], 16)
return gray_to_binary(raw) # if your encoder is Gray-coded
def gray_to_binary(g):
b = g
while g := g >> 1:
b ^= g
return b
while True:
print(read_position())
```
## Status LED
The on-board WS2812 indicates state:
- **Yellow** — booting
- **Green** — idle, ready
- **Blue** — read in progress
- **Red** — last operation failed (loopback test only)
## Troubleshooting
**All bits read as 0 or 0xFFFF... when no encoder is connected.** This is
correct — the MAX485 receiver biases the line to one rail when nothing is
driving it. Real data only appears once the encoder is wired up and powered.
**Encoder reads return constant nonsense values.** Most likely the
differential pair polarity is reversed on one of the lines. Swap A and B
on the affected module.
**Reads work but the number doesn't match shaft rotation.** Check the
`bits` parameter against the encoder's datasheet — many encoders include
a leading zero or trailing alarm/parity bit that you must mask off. If the
number changes monotonically with rotation but jumps around in unexpected
ways, the encoder is probably Gray-coded; convert before interpreting.
**Reads occasionally fail or return garbage at high speeds.** Try a larger
`half_us` value. Encoder cable length, twisted-pair quality, and
termination all affect maximum reliable clock speed. SSI specs typically
allow up to 12 MHz; 100200 kHz is a safe starting point.
**The Pico hangs after the first read.** PIO state-machine reconfiguration
between reads of different speeds requires a clean restart. Make sure you're
on the latest firmware — early versions had a FIFO drain bug.
**The serial port doesn't show up after flashing.** The first flash via
BOOTSEL exposes the Pico as a mass-storage device, not a serial port. After
the firmware is running, unplug and replug the USB cable — it should then
enumerate as a COM/ACM device.
## License
MIT — do whatever you want with this, attribution appreciated but not required.
## Acknowledgements
Born out of frustration trying to read SSI absolute encoders without paying
for an industrial gateway, and the realization that the cheapest commercial
solution costs more than this entire build does ten times over.

View file

@ -41,6 +41,24 @@ void setStatus(uint8_t r, uint8_t g, uint8_t b) {
led.show();
}
// Map encoder position to a colour on the HSV wheel (brightness fixed at ~20/255).
// hue 0-359: 0=red, 120=green, 240=blue
void setPositionColor(uint64_t value, uint64_t maxVal) {
if (maxVal == 0) { setStatus(16, 0, 16); return; } // magenta = unknown range
uint16_t hue = (uint16_t)((value * 360) / maxVal); // 0-359
uint8_t sector = hue / 60;
uint8_t frac = (uint8_t)(((hue % 60) * 255) / 60);
uint8_t v = 20, p = 0, q = (uint8_t)(v * (255 - frac) / 255), t = (uint8_t)(v * frac / 255);
switch (sector % 6) {
case 0: setStatus(v, t, p); break;
case 1: setStatus(q, v, p); break;
case 2: setStatus(p, v, t); break;
case 3: setStatus(p, q, v); break;
case 4: setStatus(t, p, v); break;
case 5: setStatus(v, p, q); break;
}
}
// =========================================================================
// CORE 1: SSI worker
// =========================================================================
@ -105,6 +123,7 @@ void requestSsiRead(uint8_t bits, uint16_t half_us, uint64_t& outValue, uint32_t
void handleCommand(const String& cmd) {
if (!cmd.startsWith("READ ")) {
setStatus(16, 0, 0); // red = error
Serial.println("ERR unknown command. Use: READ <bits> <half_us>");
return;
}
@ -112,6 +131,7 @@ void handleCommand(const String& cmd) {
int firstSpace = cmd.indexOf(' ');
int secondSpace = cmd.indexOf(' ', firstSpace + 1);
if (secondSpace < 0) {
setStatus(16, 0, 0); // red = error
Serial.println("ERR usage: READ <bits> <half_us>");
return;
}
@ -120,19 +140,24 @@ void handleCommand(const String& cmd) {
int halfUs = cmd.substring(secondSpace + 1).toInt();
if (bits < 1 || bits > 64) {
setStatus(16, 0, 0); // red = error
Serial.println("ERR bits must be 1..64");
return;
}
if (halfUs < 1 || halfUs > 10000) {
setStatus(16, 0, 0); // red = error
Serial.println("ERR half_us must be 1..10000");
return;
}
setStatus(0, 0, 16); // blue = reading
setStatus(0, 0, 16); // blue = reading
uint64_t value;
uint32_t took;
requestSsiRead((uint8_t)bits, (uint16_t)halfUs, value, took);
setStatus(0, 16, 0); // green = idle
// Show encoder position as hue (full range = 2^bits - 1)
uint64_t maxVal = (bits < 64) ? ((1ull << bits) - 1) : UINT64_MAX;
setPositionColor(value, maxVal);
Serial.printf("OK bits=%d half_us=%d hex=0x%llX dec=%llu took=%luus\n",
bits, halfUs, value, value, took);