# SP PRO serial protocol This documents the Selectronic SP PRO serial "memory" protocol as implemented in `src/sppro.c`. It is a **port of the open-source Python project [neerolyte/selpi](https://github.com/neerolyte/selpi)** (`memory/*.py`), cross-checked against the Go project [angus-g/splink-influx](https://github.com/angus-g/splink-influx). > **Note on PyAware.** The project README pointed at PyAware as prior art. PyAware > (PyPI, by Ampcontrol Group) is **not open source** — its PyPI metadata references a > private SVN repository — so it could not be ported directly. `selpi` implements the > same wire protocol and is fully open, so it was used as the reference instead. ## Physical layer | Setting | Value | |---|---| | Interface | **RS-232** (not RS-485), DB9 or RJ45 jack on the SP PRO | | Baud | **57600** (Port 1; Port 2 defaults to 9600) | | Framing | 8 data bits, no parity, 1 stop bit (8N1) | | Flow control | none | See [HARDWARE.md](HARDWARE.md) for pinout and ESP32 wiring. ## Frames All multi-byte fields are **little-endian**. A frame is built from an 8-byte header: ``` byte 0 message type: 'Q' (0x51) query/read, or 'W' (0x57) write byte 1 word count minus one (0x00 = 1 word, 0xff = 256 words) bytes 2..5 memory address (uint32, little-endian) bytes 6..7 CRC-16 over bytes 0..5 (little-endian) ``` ### Query (read) Request is just the 8-byte header. The response echoes those 8 bytes, appends the requested data (`words * 2` bytes), then a final CRC-16 over the whole response: ``` request : Q | len-1 | addr[4] | crc[2] response: Q | len-1 | addr[4] | crc[2] | data[words*2] | crc[2] ``` Worked example (documented in selpi) — read 1 word at `0xa000`: ``` request : 51 00 00 a0 00 00 9d 4b response: 51 00 00 a0 00 00 9d 4b 01 00 d8 19 -> data word = 0x0001 ``` ### Write Header (with its own CRC over bytes 0..5), then the data words, then a CRC-16 over the whole message. The device echoes the entire request back to confirm: ``` request : W | len-1 | addr[4] | crc[2] | data[words*2] | crc[2] response: ``` ### CRC-16 Reflected CRC-CCITT ("Kermit"): 256-entry table, init `0x0000`, `crc = (crc >> 8) ^ table[(crc ^ byte) & 0xff]`, emitted little-endian. A received frame is valid when the CRC computed over the **entire** message (data + trailing CRC) equals `0`. Implemented as `sppro_crc16()`. ## Login (MD5 challenge / response) Reading data registers requires authenticating first: 1. **Query** 8 words (16 bytes) at `0x1F0000` → challenge `seed`. 2. Build `md5( seed[16] + password_padded_to_32_bytes )`. The password is the SP PRO's serial-port password (blank if none); it is padded/truncated to 32 bytes with **spaces**. 3. **Swap each adjacent byte pair** of the 16-byte digest (a Selectronic endianness quirk) → 16-byte response. 4. **Write** the 16-byte response back to `0x1F0000`. 5. **Query** 1 word at `0x1F0010`; value `1` means authenticated. Pure helper: `sppro_login_response()`. Full handshake: `sppro_session_login()`. ## Scaling Raw register words are integers; engineering units come from per-device scale factors read once at startup from six consecutive words at `41000`: | Address | Name | |---|---| | 41000 | CommonScaleForAcVolts | | 41001 | CommonScaleForAcCurrent | | 41002 | CommonScaleForDcVolts | | 41003 | CommonScaleForDcCurrent | | 41004 | CommonScaleForTemperature | | 41005 | CommonScaleForInternalVoltages | With `MAGIC = 32768.0` (from selpi `memory/converter.py`): | Conversion | Formula | |---|---| | `ac_w` | `raw * AcVolts * AcCurrent / (MAGIC * 800)` | | `ac_w_signed` | `raw * AcVolts * AcCurrent / (MAGIC * 100)` | | `ac_wh` | `raw * 24 * AcVolts * AcCurrent / (MAGIC * 100)` | | `dc_w` | `raw * DcVolts * DcCurrent / (MAGIC * 100)` | | `dc_wh` | `raw * 24 * DcVolts * DcCurrent / (MAGIC * 100)` | | `dc_v` | `raw * DcVolts / (MAGIC * 10)` | | `temperature` | `raw * Temperature / MAGIC` | | `percent` | `raw / 256` | | `shunt_name` | enum → string (`sppro_shunt_name`) | ## Register map The curated subset shipped in `SPPRO_REGISTERS` (transcribed from selpi `memory/variable.py`): | Name | Address | Type | Conversion | Units | |---|---|---|---|---| | BatteryVolts | 0xa05c | u16 | dc_v | V | | BatteryTemperature | 0xa03c | u16 | temperature | C | | DCBatteryPower | 0xa02f | s32 | dc_w | W | | BattSocPercent | 41089 | u16 | percent | % | | LoadAcPower | 41107 | u32 | ac_w | W | | CombinedKacoAcPowerHiRes | 0xa3a8 | u32 | ac_w | W (AC solar) | | ACGeneratorPower | 41098 | s16 | ac_w_signed | W | | Shunt1Power / Shunt2Power | 0xa088 / 0xa089 | s16 | dc_w | W | | Shunt1Name / Shunt2Name | 0xc109 / 0xc10a | s16 | shunt_name | — | | DCkWhInToday / DCkWhOutToday | 41135 / 41137 | u32 | dc_wh | Wh | | BattInkWhTotalAcc / BattOutkWhTotalAcc | 41354 / 41381 | u32 | dc_wh | Wh | | ACLoadkWhTotalAcc | 41438 | u32 | ac_wh | Wh | | TotalKacokWhTotalAcc | 41519 | u32 | ac_wh | Wh | selpi's `memory/variable.py` lists many more accumulators (7/30/365-day, yearly, resettable); add rows to `SPPRO_REGISTERS` as needed — the table is the single source of truth and decoding is data-driven. ## Credits - [neerolyte/selpi](https://github.com/neerolyte/selpi) — primary reference (MIT-style, see repo). - [angus-g/splink-influx](https://github.com/angus-g/splink-influx) — independent confirmation.