diff --git a/README.md b/README.md new file mode 100644 index 0000000..40180b1 --- /dev/null +++ b/README.md @@ -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, 1–32 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 10–30 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 ` + +Performs one SSI read. + +- `bits` — number of data bits to clock out, 1–32 +- `half_us` — duration of one CLK half-period in microseconds, 1–10000 + +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 `. + +### 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 1–2 MHz; 100–200 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. \ No newline at end of file