From e139079a3cf523a2896e6df7e6cea15d4e7c7105 Mon Sep 17 00:00:00 2001 From: Scott Penrose Date: Tue, 16 Jun 2026 04:45:59 +1000 Subject: [PATCH] First version getting ready to release --- .gitignore | 18 ++ Makefile | 33 ++++ README.md | 125 ++++++++++++++ docs/HARDWARE.md | 72 ++++++++ docs/PROTOCOL.md | 137 +++++++++++++++ esp32/sppro_esp32.ino | 105 ++++++++++++ host/main.c | 143 ++++++++++++++++ src/sppro.c | 388 ++++++++++++++++++++++++++++++++++++++++++ tests/test_sppro.c | 185 ++++++++++++++++++++ 9 files changed, 1206 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 docs/HARDWARE.md create mode 100644 docs/PROTOCOL.md create mode 100644 esp32/sppro_esp32.ino create mode 100644 host/main.c create mode 100644 src/sppro.c create mode 100644 tests/test_sppro.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad5eada --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Build output +/build/ +*.o +*.a +*.elf +*.bin + +# PlatformIO (ESP32) +.pio/ +.pioenvs/ +.piolibdeps/ + +# Editor / OS +*.swp +*~ +.DS_Store +.vscode/ +.idea/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..96a4116 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +# SPPro - portable C parser for Selectronic SP PRO serial data. + +CC ?= cc +CFLAGS ?= -std=c99 -Wall -Wextra -Wpedantic -O2 +LDFLAGS ?= + +SRC := src/sppro.c src/md5.c +HDR := src/sppro.h src/md5.h + +BUILD := build + +.PHONY: all test host clean + +all: test host + +# Host unit tests (no hardware required). +$(BUILD)/test_sppro: tests/test_sppro.c $(SRC) $(HDR) | $(BUILD) + $(CC) $(CFLAGS) -o $@ tests/test_sppro.c $(SRC) -lm + +test: $(BUILD)/test_sppro + ./$(BUILD)/test_sppro + +# Linux serial-console dashboard demo. +$(BUILD)/sppro-console: host/main.c $(SRC) $(HDR) | $(BUILD) + $(CC) $(CFLAGS) -o $@ host/main.c $(SRC) -lm + +host: $(BUILD)/sppro-console + +$(BUILD): + mkdir -p $(BUILD) + +clean: + rm -rf $(BUILD) diff --git a/README.md b/README.md index fd2f42e..ba7e3c9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,131 @@ PyAware seems to already decode Serial Data. Look at porting that to portable C * Parse Serial Data * A simple C parser, portable to allow parsing +This repo now contains a **portable C parser** for the SP PRO serial protocol, ported +from [neerolyte/selpi](https://github.com/neerolyte/selpi) (PyAware itself is not open +source). Full protocol notes are in [docs/PROTOCOL.md](docs/PROTOCOL.md) and the ESP32 +wiring in [docs/HARDWARE.md](docs/HARDWARE.md). + +## Layout + +``` +src/ sppro.h / sppro.c portable core: CRC, frame build/parse, + md5.h / md5.c register map + scaling, MD5 login, session layer +host/ main.c Linux serial-console dashboard (termios) +esp32/ sppro_esp32.ino ESP32 (Arduino) example, reuses the core unchanged +tests/ test_sppro.c host known-answer tests (no hardware) +docs/ PROTOCOL.md wire protocol, CRC, login, register map + scaling + HARDWARE.md RS-232 pinout and MAX3232 -> ESP32 wiring +``` + +The core (`src/`) is C99, **no dynamic allocation and no I/O** — it builds/parses byte +buffers and converts raw words to units. I/O is supplied by the caller through a small +`sppro_transport_t` read/write callback, so the same code runs on a host or an MCU. + +## Build & test + +```sh +make test # build and run the host unit tests (no hardware needed) +make host # build ./build/sppro-console +``` + +## Run against a real SP PRO + +Wire it up per [docs/HARDWARE.md](docs/HARDWARE.md) (RS-232, **needs a MAX3232 level +shifter**, 57600 8N1), then: + +```sh +./build/sppro-console /dev/ttyUSB0 [serial_password] [interval_seconds] +``` + +It logs in, reads the device scale factors, and prints a refreshing dashboard of battery +voltage, state of charge, load/solar/generator power, and energy totals. The serial-port +password is set on the inverter (front panel → Settings → Communications); pass `""` if +none is configured, or set `SPPRO_PASSWORD`. + +## Using the core in another project + +```c +#include "sppro.h" + +sppro_transport_t t = { my_read, my_write, my_ctx }; /* your serial callbacks */ +sppro_session_login(&t, ""); /* MD5 challenge/response */ + +sppro_scales_t scales; +sppro_session_read_scales(&t, &scales); + +double soc, vbat; +sppro_session_read(&t, sppro_reg_by_name("BattSocPercent"), &scales, &soc); +sppro_session_read(&t, sppro_reg_by_name("BatteryVolts"), &scales, &vbat); +``` + +For environments with their own protocol loop, the pure helpers (`sppro_build_query`, +`sppro_parse_query_response`, `sppro_decode`, `sppro_crc16`, `sppro_login_response`) can +be used without the session/transport layer. + +## Status & caveats + +- Verified by host unit tests: CRC against selpi's documented `0xa000` frame, MD5 + known-answers, frame round-trips, and the scaling formulas. **Not yet verified against + physical hardware** — confirm decoded values against the inverter's own display. +- Read-only monitoring is the focus. Write framing (`sppro_build_write` / + `sppro_session_write`) exists because login needs it, but no inverter-setting writes + are wired into the demos. +- The register table is a useful subset; add rows from selpi `memory/variable.py` as + needed (decoding is table-driven). + +## Not-yet-mapped registers (future work) + +`SPPRO_REGISTERS` covers all the live measurements plus the most useful energy totals. +The following entries exist in selpi `memory/variable.py` but are **not** in the table +yet. All are decodable by the existing converters — adding them is pure data (new rows), +no new code. + +Scale factor (already used internally via `sppro_scales_t`, just not exposed as a row): + +| Name | Addr | Type | Conv | +|---|---|---|---| +| CommonScaleForInternalVoltages | 41005 | u16 | raw | + +Battery "out" energy accumulators (all `u32`, `dc_wh`): + +| Name | Addr | +|---|---| +| BattOutkWhAcc | 41143 | +| QuickView_BattOutkWhAcc | 41178 | +| BattOutkWhPreviousAcc | 41356 | +| BattOutkWh7DayAcc / …AccAvg | 41358 / 41360 | +| BattOutkWh30DayAcc / …AccAvg | 41362 / 41364 | +| BattOutkWh365DayAcc / …AccAvg | 41366 / 41368 | +| BattOutkWhYearAcc / …AccAvg | 41370 / 41372 | +| BattOutkWhResetableAcc / …AccAvg | 41374 / 41376 | + +Other DC energy: + +| Name | Addr | Type | Conv | +|---|---|---|---| +| DCkWhOut | 41257 | u32 | dc_wh | +| Shunt1WhTotalAcc | 41305 | s32 | dc_wh | +| Shunt1WhTodayAcc | 41146 | s16 | dc_wh | + +AC energy (input / export / load / solar): + +| Name | Addr | Type | Conv | +|---|---|---|---| +| ACInputWhTotalAcc | 41459 | u32 | ac_wh | +| ACInputWhTodayAcc | 41151 | u16 | ac_wh | +| ACExportWhTotalAcc | 41499 | u32 | ac_wh | +| ACExportWhTodayAcc | 41154 | u16 | ac_wh | +| ACLoadWhAcc | 41150 | u16 | ac_wh | +| ACSolarWhTotalAcc | 41519 | u32 | ac_wh | +| ACSolarWhTodayAcc | 41157 | s16 | ac_wh | + +Two quirks when transcribing these: + +- `ACSolarWhTotalAcc` (41519) is the **same address** as the already-included + `TotalKacokWhTotalAcc` (41519) — selpi just has two names for it. +- `ACGeneratorPower` appears twice in selpi's MAP; the second definition wins (`s16`, + `ac_w_signed`) — that is the one already in the table. The first (`u32`, `ac_w`) is dead. ## See Also diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md new file mode 100644 index 0000000..018df08 --- /dev/null +++ b/docs/HARDWARE.md @@ -0,0 +1,72 @@ +# Connecting an ESP32 to the SP PRO serial port + +The SP PRO serial port is **RS-232** (±12 V signalling), not RS-485 and not logic-level +UART. You therefore **must** put an RS-232 ↔ 3.3 V level shifter between the inverter and +the ESP32 — wiring the SP PRO directly to an ESP32 GPIO will damage the ESP32. + +Reference: Selectronic Tech Note +[TN0050 "SP PRO Serial Port Pin-out"](https://www.selectronic.com.au/documents/TechNotes/TN0050_02%20SP%20PRO%20Serial%20Port%20Pin-out.pdf), +cross-checked with the [selpi `connecting.md`](https://github.com/neerolyte/selpi/blob/main/docs/connecting.md) +(which connects via an FT232 USB-to-RS232 cable at 57600 baud). + +## SP PRO RJ45 pinout (TN0050) + +| RJ45 pin | Signal | Use | +|---|---|---| +| 1 | +12 V isolated (1 A) | optional supply — **do not** feed the ESP32 directly | +| 2 | DTR | not needed | +| **3** | **TXD** | SP PRO → us | +| **4** | **GND** | common ground (required) | +| 5 | GND | ground (redundant) | +| **6** | **RXD** | us → SP PRO | +| 7 | DCD | not needed | +| 8 | — | not connected | + +Only **TXD, RXD and GND** are needed. Settings: **57600 baud, 8N1, no flow control.** + +## Level shifter: MAX3232 + +A **MAX3232** (3.3 V RS-232 transceiver with internal charge pump) is the right part — +it is the 3.3 V-capable successor to the MAX232. RS-485 transceivers (MAX3485 etc.) are +**not** suitable here. Add the four 0.1 µF charge-pump capacitors per its datasheet. + +## Wiring + +``` + SP PRO (RS-232) MAX3232 ESP32 (3.3V UART2) + --------------- --------- ------------------ + RJ45 pin 3 TXD -------> R1IN + R1OUT -------------> GPIO16 (RX2) + RJ45 pin 6 RXD <------- T1OUT + T1IN <------------- GPIO17 (TX2) + RJ45 pin 4 GND -------+- GND ----------------+- GND (common!) + | | + 3.3V ----------------- VCC (4x 0.1uF charge-pump caps) +``` + +- **TXD of one side always goes to RXD of the other** — note the crossover above. +- **Common ground is mandatory.** RS-232 is single-ended and needs a shared reference. +- RS-232 has no direction-control line (unlike RS-485), so there is nothing equivalent to + DE/RE to manage. + +## ESP32 UART + +Use **UART2** to keep UART0 free for USB logging/flashing: + +```cpp +Serial2.begin(57600, SERIAL_8N1, /*RX=*/16, /*TX=*/17); +``` + +GPIO16/17 are safe general-purpose pins on most ESP32 dev boards. (On a few modules with +PSRAM these pins are reserved — pick two other free UART-capable GPIOs and update the +sketch if so.) See `esp32/sppro_esp32.ino`. + +## Quick bring-up checks + +1. Confirm the SP PRO serial port is enabled and note its **baud** and **password** + (front panel → Settings → Communications). +2. Loopback-test the MAX3232: short its TTL TX→RX and echo bytes through the ESP32. +3. With it wired to the SP PRO, the first query at `0xa000` should echo back; the + `sppro-console` demo (or the sketch) will report "connected" once login succeeds. +4. If login fails, the usual causes are wrong/blank password, swapped TX/RX, or missing + common ground. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..978bcc0 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,137 @@ +# 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. diff --git a/esp32/sppro_esp32.ino b/esp32/sppro_esp32.ino new file mode 100644 index 0000000..0fed8bf --- /dev/null +++ b/esp32/sppro_esp32.ino @@ -0,0 +1,105 @@ +/* + * sppro_esp32.ino - read a Selectronic SP PRO from an ESP32 over UART2. + * + * Reuses the portable core unchanged. Copy src/sppro.c, src/sppro.h, src/md5.c + * and src/md5.h next to this .ino (Arduino compiles all source in the sketch + * folder), or add src/ as a library. + * + * Wiring (see docs/HARDWARE.md) - the SP PRO port is RS-232 (+/-12V), so a + * level shifter is required; do NOT wire it straight to the ESP32: + * + * SP PRO RJ45 pin 3 (TXD) --> MAX3232 R1IN ; R1OUT --> ESP32 GPIO16 (RX2) + * ESP32 GPIO17 (TX2) --> MAX3232 T1IN ; T1OUT --> SP PRO RJ45 pin 6 (RXD) + * SP PRO RJ45 pin 4/5 (GND)--> MAX3232 GND --> ESP32 GND (common ground!) + * ESP32 3V3 --> MAX3232 VCC (+ four 0.1uF charge-pump caps) + * + * Set SPPRO_PASSWORD to the inverter's serial-port password ("" if none). + */ +extern "C" { +#include "sppro.h" +} + +static const int PIN_RX = 16; /* ESP32 RX2 <- MAX3232 R1OUT */ +static const int PIN_TX = 17; /* ESP32 TX2 -> MAX3232 T1IN */ +static const char *SPPRO_PASSWORD = ""; + +static sppro_scales_t g_scales; +static bool g_ready = false; + +/* ----- transport bound to Serial2 ---------------------------------------- */ + +static int esp_read(void *ctx, uint8_t *buf, size_t len) { + (void)ctx; + size_t got = 0; + /* brief wait so a frame that is mid-flight is not reported as "empty" */ + unsigned long start = millis(); + while (got < len && (millis() - start) < 200) { + while (Serial2.available() && got < len) { + buf[got++] = (uint8_t)Serial2.read(); + start = millis(); + } + if (got == 0) delay(1); + else break; + } + return (int)got; /* 0 on timeout */ +} + +static int esp_write(void *ctx, const uint8_t *buf, size_t len) { + (void)ctx; + return (int)Serial2.write(buf, len); +} + +static sppro_transport_t g_transport = { esp_read, esp_write, nullptr }; + +/* ----- sketch ------------------------------------------------------------- */ + +static bool connect_sppro() { + if (sppro_session_login(&g_transport, SPPRO_PASSWORD) != SPPRO_OK) { + Serial.println("login failed - check wiring/password"); + return false; + } + if (sppro_session_read_scales(&g_transport, &g_scales) != SPPRO_OK) { + Serial.println("could not read scale factors"); + return false; + } + Serial.println("connected to SP PRO"); + return true; +} + +static void show(const char *name) { + const sppro_reg_t *reg = sppro_reg_by_name(name); + double value; + if (!reg) return; + if (sppro_session_read(&g_transport, reg, &g_scales, &value) != SPPRO_OK) { + Serial.printf("%-26s (read error)\n", reg->description); + return; + } + if (reg->conv == SPPRO_C_SHUNT_NAME) + Serial.printf("%-26s %12s\n", reg->description, sppro_shunt_name((int)value)); + else + Serial.printf("%-26s %12.2f %s\n", reg->description, value, reg->units); +} + +void setup() { + Serial.begin(115200); + Serial2.begin(SPPRO_BAUD, SERIAL_8N1, PIN_RX, PIN_TX); + delay(200); + g_ready = connect_sppro(); +} + +void loop() { + if (!g_ready) { + delay(2000); + g_ready = connect_sppro(); + return; + } + Serial.println("\n== Selectronic SP PRO =="); + show("BatteryVolts"); + show("BattSocPercent"); + show("BatteryTemperature"); + show("DCBatteryPower"); + show("LoadAcPower"); + show("CombinedKacoAcPowerHiRes"); + show("ACGeneratorPower"); + delay(5000); +} diff --git a/host/main.c b/host/main.c new file mode 100644 index 0000000..fdd9b66 --- /dev/null +++ b/host/main.c @@ -0,0 +1,143 @@ +/* + * sppro-console - Linux serial-console dashboard for the Selectronic SP PRO. + * + * Opens the SP PRO serial port (57600 8N1), logs in, then polls the key + * registers on an interval and prints a refreshing table. Demonstrates wiring + * the portable core (src/sppro.c) to a real transport via termios. + * + * make host + * ./build/sppro-console /dev/ttyUSB0 [password] [interval_seconds] + * + * The password is the SP PRO serial-port password (Settings -> Comms). Pass "" + * (or omit) if no password is configured. It may also be set via SPPRO_PASSWORD. + */ +#define _DEFAULT_SOURCE /* cfmakeraw, CRTSCTS, sleep() under -std=c99 */ + +#include "../src/sppro.h" + +#include +#include +#include +#include +#include +#include +#include + +/* ----- termios transport -------------------------------------------------- */ + +static int serial_read(void *ctx, uint8_t *buf, size_t len) +{ + int fd = *(int *)ctx; + ssize_t n = read(fd, buf, len); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN) return 0; + return -1; + } + return (int)n; /* 0 on VTIME timeout */ +} + +static int serial_write(void *ctx, const uint8_t *buf, size_t len) +{ + int fd = *(int *)ctx; + ssize_t n = write(fd, buf, len); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN) return 0; + return -1; + } + return (int)n; +} + +static int serial_open(const char *path) +{ + struct termios tio; + int fd = open(path, O_RDWR | O_NOCTTY); + if (fd < 0) { perror("open"); return -1; } + + if (tcgetattr(fd, &tio) != 0) { perror("tcgetattr"); close(fd); return -1; } + cfmakeraw(&tio); + cfsetispeed(&tio, B57600); + cfsetospeed(&tio, B57600); + tio.c_cflag |= (CLOCAL | CREAD); + tio.c_cflag &= ~CSTOPB; /* 1 stop bit */ + tio.c_cflag &= ~PARENB; /* no parity */ + tio.c_cflag &= ~CRTSCTS; /* no hardware flow control */ + tio.c_cc[VMIN] = 0; /* non-blocking with timeout */ + tio.c_cc[VTIME] = 2; /* 0.2s read timeout */ + if (tcsetattr(fd, TCSANOW, &tio) != 0) { perror("tcsetattr"); close(fd); return -1; } + tcflush(fd, TCIOFLUSH); + return fd; +} + +/* ----- dashboard ---------------------------------------------------------- */ + +/* Registers shown on the dashboard (names must exist in SPPRO_REGISTERS). */ +static const char *const DASHBOARD[] = { + "BatteryVolts", "BattSocPercent", "BatteryTemperature", "DCBatteryPower", + "LoadAcPower", "CombinedKacoAcPowerHiRes", "ACGeneratorPower", + "Shunt1Power", "Shunt2Power", + "DCkWhInToday", "DCkWhOutToday", "ACLoadkWhTotalAcc", "TotalKacokWhTotalAcc", +}; + +static void print_dashboard(const sppro_transport_t *t, const sppro_scales_t *scales) +{ + size_t i; + printf("\033[2J\033[H"); /* clear screen, home cursor */ + printf("== Selectronic SP PRO ==\n\n"); + for (i = 0; i < sizeof(DASHBOARD) / sizeof(DASHBOARD[0]); i++) { + const sppro_reg_t *reg = sppro_reg_by_name(DASHBOARD[i]); + double value; + int rc; + if (!reg) continue; + rc = sppro_session_read(t, reg, scales, &value); + if (rc != SPPRO_OK) { + printf(" %-26s (read error %d)\n", reg->description, rc); + continue; + } + if (reg->conv == SPPRO_C_SHUNT_NAME) + printf(" %-26s %12s\n", reg->description, sppro_shunt_name((int)value)); + else + printf(" %-26s %12.2f %-3s\n", reg->description, value, reg->units); + } + fflush(stdout); +} + +int main(int argc, char **argv) +{ + const char *path = (argc > 1) ? argv[1] : "/dev/ttyUSB0"; + const char *password = (argc > 2) ? argv[2] : getenv("SPPRO_PASSWORD"); + int interval = (argc > 3) ? atoi(argv[3]) : 5; + int fd, rc; + sppro_transport_t t; + sppro_scales_t scales; + + if (!password) password = ""; + if (interval < 1) interval = 1; + + fd = serial_open(path); + if (fd < 0) return 1; + + t.read = serial_read; + t.write = serial_write; + t.ctx = &fd; + + rc = sppro_session_login(&t, password); + if (rc != SPPRO_OK) { + fprintf(stderr, "login failed (%d) - check cabling and serial password\n", rc); + close(fd); + return 1; + } + rc = sppro_session_read_scales(&t, &scales); + if (rc != SPPRO_OK) { + fprintf(stderr, "could not read scale factors (%d)\n", rc); + close(fd); + return 1; + } + + for (;;) { + print_dashboard(&t, &scales); + sleep((unsigned)interval); + } + + close(fd); /* not reached */ + return 0; +} diff --git a/src/sppro.c b/src/sppro.c new file mode 100644 index 0000000..86e79c3 --- /dev/null +++ b/src/sppro.c @@ -0,0 +1,388 @@ +/* + * sppro.c - Portable C parser for Selectronic SP PRO serial data. + * See sppro.h for the protocol summary and credits (ported from neerolyte/selpi). + */ +#include "sppro.h" +#include "md5.h" +#include + +/* ----- CRC: reflected CRC-CCITT ("Kermit"), table from selpi memory/crc.py --- */ + +static const uint16_t SPPRO_FCS[256] = { + 0x0000,0x1189,0x2312,0x329b,0x4624,0x57ad,0x6536,0x74bf,0x8c48,0x9dc1,0xaf5a,0xbed3,0xca6c,0xdbe5,0xe97e,0xf8f7, + 0x1081,0x0108,0x3393,0x221a,0x56a5,0x472c,0x75b7,0x643e,0x9cc9,0x8d40,0xbfdb,0xae52,0xdaed,0xcb64,0xf9ff,0xe876, + 0x2102,0x308b,0x0210,0x1399,0x6726,0x76af,0x4434,0x55bd,0xad4a,0xbcc3,0x8e58,0x9fd1,0xeb6e,0xfae7,0xc87c,0xd9f5, + 0x3183,0x200a,0x1291,0x0318,0x77a7,0x662e,0x54b5,0x453c,0xbdcb,0xac42,0x9ed9,0x8f50,0xfbef,0xea66,0xd8fd,0xc974, + 0x4204,0x538d,0x6116,0x709f,0x0420,0x15a9,0x2732,0x36bb,0xce4c,0xdfc5,0xed5e,0xfcd7,0x8868,0x99e1,0xab7a,0xbaf3, + 0x5285,0x430c,0x7197,0x601e,0x14a1,0x0528,0x37b3,0x263a,0xdecd,0xcf44,0xfddf,0xec56,0x98e9,0x8960,0xbbfb,0xaa72, + 0x6306,0x728f,0x4014,0x519d,0x2522,0x34ab,0x0630,0x17b9,0xef4e,0xfec7,0xcc5c,0xddd5,0xa96a,0xb8e3,0x8a78,0x9bf1, + 0x7387,0x620e,0x5095,0x411c,0x35a3,0x242a,0x16b1,0x0738,0xffcf,0xee46,0xdcdd,0xcd54,0xb9eb,0xa862,0x9af9,0x8b70, + 0x8408,0x9581,0xa71a,0xb693,0xc22c,0xd3a5,0xe13e,0xf0b7,0x0840,0x19c9,0x2b52,0x3adb,0x4e64,0x5fed,0x6d76,0x7cff, + 0x9489,0x8500,0xb79b,0xa612,0xd2ad,0xc324,0xf1bf,0xe036,0x18c1,0x0948,0x3bd3,0x2a5a,0x5ee5,0x4f6c,0x7df7,0x6c7e, + 0xa50a,0xb483,0x8618,0x9791,0xe32e,0xf2a7,0xc03c,0xd1b5,0x2942,0x38cb,0x0a50,0x1bd9,0x6f66,0x7eef,0x4c74,0x5dfd, + 0xb58b,0xa402,0x9699,0x8710,0xf3af,0xe226,0xd0bd,0xc134,0x39c3,0x284a,0x1ad1,0x0b58,0x7fe7,0x6e6e,0x5cf5,0x4d7c, + 0xc60c,0xd785,0xe51e,0xf497,0x8028,0x91a1,0xa33a,0xb2b3,0x4a44,0x5bcd,0x6956,0x78df,0x0c60,0x1de9,0x2f72,0x3efb, + 0xd68d,0xc704,0xf59f,0xe416,0x90a9,0x8120,0xb3bb,0xa232,0x5ac5,0x4b4c,0x79d7,0x685e,0x1ce1,0x0d68,0x3ff3,0x2e7a, + 0xe70e,0xf687,0xc41c,0xd595,0xa12a,0xb0a3,0x8238,0x93b1,0x6b46,0x7acf,0x4854,0x59dd,0x2d62,0x3ceb,0x0e70,0x1ff9, + 0xf78f,0xe606,0xd49d,0xc514,0xb1ab,0xa022,0x92b9,0x8330,0x7bc7,0x6a4e,0x58d5,0x495c,0x3de3,0x2c6a,0x1ef1,0x0f78 +}; + +uint16_t sppro_crc16(const uint8_t *msg, size_t len) +{ + uint16_t n = 0; + size_t i; + for (i = 0; i < len; i++) + n = (uint16_t)((n >> 8) ^ SPPRO_FCS[(n ^ msg[i]) & 0xff]); + return n; +} + +/* ----- little-endian word access ----------------------------------------- */ + +uint16_t sppro_u16(const uint8_t *p) { return (uint16_t)(p[0] | (p[1] << 8)); } +int16_t sppro_s16(const uint8_t *p) { return (int16_t)sppro_u16(p); } +uint32_t sppro_u32(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} +int32_t sppro_s32(const uint8_t *p) { return (int32_t)sppro_u32(p); } + +static void put_u16le(uint8_t *p, uint16_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); } +static void put_u32le(uint8_t *p, uint32_t v) +{ + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); +} + +/* ----- frame build / parse ------------------------------------------------ */ + +/* Build the 8-byte header: type | (words-1) | addr[4] | crc16(first 6). */ +static void build_header(uint8_t *out, uint8_t type, uint32_t address, int words) +{ + out[0] = type; + out[1] = (uint8_t)(words - 1); + put_u32le(out + 2, address); + put_u16le(out + 6, sppro_crc16(out, 6)); +} + +int sppro_build_query(uint8_t *out, size_t out_cap, uint32_t address, int words) +{ + if (!out || words < 1 || words > SPPRO_MAX_WORDS) + return SPPRO_ERR_ARG; + if (out_cap < 8) + return SPPRO_ERR_BUFFER; + build_header(out, 'Q', address, words); + return 8; +} + +int sppro_build_write(uint8_t *out, size_t out_cap, uint32_t address, + const uint8_t *data, size_t data_len) +{ + size_t total; + int words; + if (!out || !data || data_len < 2 || (data_len & 1)) + return SPPRO_ERR_ARG; + words = (int)(data_len / 2); + if (words > SPPRO_MAX_WORDS) + return SPPRO_ERR_ARG; + total = 8 + data_len + 2; + if (out_cap < total) + return SPPRO_ERR_BUFFER; + build_header(out, 'W', address, words); + memcpy(out + 8, data, data_len); + put_u16le(out + 8 + data_len, sppro_crc16(out, 8 + data_len)); + return (int)total; +} + +size_t sppro_query_response_len(int words) +{ + return (size_t)(8 + words * 2 + 2); +} + +int sppro_parse_query_response(const uint8_t *resp, size_t resp_len, + uint32_t address, int words, + const uint8_t **data_out, size_t *data_len_out) +{ + if (!resp || words < 1 || words > SPPRO_MAX_WORDS) + return SPPRO_ERR_ARG; + if (resp_len != sppro_query_response_len(words)) + return SPPRO_ERR_LENGTH; + if (sppro_crc16(resp, resp_len) != 0) + return SPPRO_ERR_CRC; + if (resp[0] != 'Q' || resp[1] != (uint8_t)(words - 1) || + sppro_u32(resp + 2) != address) + return SPPRO_ERR_ECHO; + if (data_out) *data_out = resp + 8; + if (data_len_out) *data_len_out = (size_t)words * 2; + return SPPRO_OK; +} + +/* ----- register map ------------------------------------------------------- */ + +const sppro_reg_t SPPRO_REGISTERS[] = { + /* Scale factors (raw, read first; feed sppro_scales_t). */ + { "CommonScaleForAcVolts", 41000, SPPRO_T_U16, SPPRO_C_RAW, "", "AC volts scale" }, + { "CommonScaleForAcCurrent", 41001, SPPRO_T_U16, SPPRO_C_RAW, "", "AC current scale" }, + { "CommonScaleForDcVolts", 41002, SPPRO_T_U16, SPPRO_C_RAW, "", "DC volts scale" }, + { "CommonScaleForDcCurrent", 41003, SPPRO_T_U16, SPPRO_C_RAW, "", "DC current scale" }, + { "CommonScaleForTemperature",41004, SPPRO_T_U16, SPPRO_C_RAW, "", "Temperature scale" }, + /* Live measurements. */ + { "BatteryVolts", 0xa05c, SPPRO_T_U16, SPPRO_C_DC_V, "V", "Battery volts" }, + { "BatteryTemperature", 0xa03c, SPPRO_T_U16, SPPRO_C_TEMPERATURE,"C", "Battery temperature" }, + { "DCBatteryPower", 0xa02f, SPPRO_T_S32, SPPRO_C_DC_W, "W", "Battery power" }, + { "BattSocPercent", 41089, SPPRO_T_U16, SPPRO_C_PERCENT, "%", "Battery state of charge" }, + { "LoadAcPower", 41107, SPPRO_T_U32, SPPRO_C_AC_W, "W", "AC load power" }, + { "CombinedKacoAcPowerHiRes", 0xa3a8, SPPRO_T_U32, SPPRO_C_AC_W, "W", "AC solar power" }, + { "ACGeneratorPower", 41098, SPPRO_T_S16, SPPRO_C_AC_W_SIGNED,"W", "AC generator power" }, + { "Shunt1Power", 0xa088, SPPRO_T_S16, SPPRO_C_DC_W, "W", "Shunt 1 power" }, + { "Shunt2Power", 0xa089, SPPRO_T_S16, SPPRO_C_DC_W, "W", "Shunt 2 power" }, + { "Shunt1Name", 0xc109, SPPRO_T_S16, SPPRO_C_SHUNT_NAME, "", "Shunt 1 name" }, + { "Shunt2Name", 0xc10a, SPPRO_T_S16, SPPRO_C_SHUNT_NAME, "", "Shunt 2 name" }, + /* Energy counters. */ + { "DCkWhInToday", 41135, SPPRO_T_U32, SPPRO_C_DC_WH, "Wh", "DC energy in today" }, + { "DCkWhOutToday", 41137, SPPRO_T_U32, SPPRO_C_DC_WH, "Wh", "DC energy out today" }, + { "BattInkWhTotalAcc", 41354, SPPRO_T_U32, SPPRO_C_DC_WH, "Wh", "Battery in energy total" }, + { "BattOutkWhTotalAcc", 41381, SPPRO_T_U32, SPPRO_C_DC_WH, "Wh", "Battery out energy total" }, + { "ACLoadkWhTotalAcc", 41438, SPPRO_T_U32, SPPRO_C_AC_WH, "Wh", "AC load energy total" }, + { "TotalKacokWhTotalAcc", 41519, SPPRO_T_U32, SPPRO_C_AC_WH, "Wh", "AC solar energy total" }, +}; +const size_t SPPRO_REGISTER_COUNT = sizeof(SPPRO_REGISTERS) / sizeof(SPPRO_REGISTERS[0]); + +const sppro_reg_t *sppro_reg_by_name(const char *name) +{ + size_t i; + if (!name) return NULL; + for (i = 0; i < SPPRO_REGISTER_COUNT; i++) + if (strcmp(SPPRO_REGISTERS[i].name, name) == 0) + return &SPPRO_REGISTERS[i]; + return NULL; +} + +int sppro_type_words(sppro_type_t type) +{ + return (type == SPPRO_T_U32 || type == SPPRO_T_S32) ? 2 : 1; +} + +int sppro_parse_scales(const uint8_t *data, size_t data_len, sppro_scales_t *out) +{ + if (!data || !out || data_len < 12) + return SPPRO_ERR_ARG; + out->ac_volts = sppro_u16(data + 0); + out->ac_current = sppro_u16(data + 2); + out->dc_volts = sppro_u16(data + 4); + out->dc_current = sppro_u16(data + 6); + out->temperature = sppro_u16(data + 8); + out->internal_voltages = sppro_u16(data + 10); + return SPPRO_OK; +} + +/* ----- unit conversion (selpi memory/converter.py) ------------------------ */ + +#define SPPRO_MAGIC 32768.0 + +double sppro_convert(sppro_conv_t conv, double raw, const sppro_scales_t *s) +{ + double acv = s ? (double)s->ac_volts : 0.0; + double aci = s ? (double)s->ac_current : 0.0; + double dcv = s ? (double)s->dc_volts : 0.0; + double dci = s ? (double)s->dc_current : 0.0; + double tmp = s ? (double)s->temperature : 0.0; + + switch (conv) { + case SPPRO_C_AC_W: return raw * acv * aci / (SPPRO_MAGIC * 800.0); + case SPPRO_C_AC_W_SIGNED: return raw * acv * aci / (SPPRO_MAGIC * 100.0); + case SPPRO_C_AC_WH: return raw * 24.0 * acv * aci / (SPPRO_MAGIC * 100.0); + case SPPRO_C_DC_W: return raw * dcv * dci / (SPPRO_MAGIC * 100.0); + case SPPRO_C_DC_WH: return raw * 24.0 * dcv * dci / (SPPRO_MAGIC * 100.0); + case SPPRO_C_DC_V: return raw * dcv / (SPPRO_MAGIC * 10.0); + case SPPRO_C_TEMPERATURE: return raw * tmp / SPPRO_MAGIC; + case SPPRO_C_PERCENT: return raw / 256.0; + case SPPRO_C_RAW: + case SPPRO_C_SHUNT_NAME: + default: return raw; + } +} + +double sppro_decode(const sppro_reg_t *reg, const uint8_t *data, + const sppro_scales_t *scales) +{ + double raw = 0.0; + if (!reg || !data) return 0.0; + switch (reg->type) { + case SPPRO_T_U16: raw = (double)sppro_u16(data); break; + case SPPRO_T_S16: raw = (double)sppro_s16(data); break; + case SPPRO_T_U32: raw = (double)sppro_u32(data); break; + case SPPRO_T_S32: raw = (double)sppro_s32(data); break; + } + return sppro_convert(reg->conv, raw, scales); +} + +static const char *const SPPRO_SHUNT_NAMES[] = { + "None", "Solar", "Wind", "Hydro", "Charger", "Load", + "Dual", "Multiple SP PROs", "Log Only", "System SoC", "Direct SoC Input" +}; + +const char *sppro_shunt_name(int raw) +{ + if (raw >= 0 && (size_t)raw < sizeof(SPPRO_SHUNT_NAMES) / sizeof(SPPRO_SHUNT_NAMES[0])) + return SPPRO_SHUNT_NAMES[raw]; + return "Error"; +} + +/* ----- login response (pure) ---------------------------------------------- */ + +void sppro_login_response(const uint8_t seed[16], const char *password, + uint8_t out[16]) +{ + uint8_t buf[48]; + uint8_t digest[16]; + size_t i, plen; + + memcpy(buf, seed, 16); + /* Pad/truncate the password to 32 bytes with spaces (Python str.ljust). */ + plen = password ? strlen(password) : 0; + if (plen > 32) plen = 32; + memcpy(buf + 16, password, plen); + for (i = 16 + plen; i < 48; i++) + buf[i] = ' '; + + sppro_md5(buf, sizeof(buf), digest); + + /* Swap each adjacent byte pair (Selectronic's endian quirk). */ + for (i = 0; i < 16; i += 2) { + out[i] = digest[i + 1]; + out[i + 1] = digest[i]; + } +} + +/* ----- transport-based session ------------------------------------------- */ + +/* Consecutive empty reads tolerated before giving up (the host transport's + * read should block briefly with a timeout and return 0 on timeout). */ +#define SPPRO_MAX_EMPTY 100 + +static int write_all(const sppro_transport_t *t, const uint8_t *buf, size_t len) +{ + size_t sent = 0; + while (sent < len) { + int n = t->write(t->ctx, buf + sent, len - sent); + if (n < 0) return SPPRO_ERR_IO; + if (n == 0) return SPPRO_ERR_IO; /* a blocking writer should not return 0 */ + sent += (size_t)n; + } + return SPPRO_OK; +} + +static int read_exact(const sppro_transport_t *t, uint8_t *buf, size_t len) +{ + size_t got = 0; + int empty = 0; + while (got < len) { + int n = t->read(t->ctx, buf + got, len - got); + if (n < 0) return SPPRO_ERR_IO; + if (n == 0) { + if (++empty >= SPPRO_MAX_EMPTY) return SPPRO_ERR_IO; + continue; + } + empty = 0; + got += (size_t)n; + } + return SPPRO_OK; +} + +int sppro_session_query(const sppro_transport_t *t, uint32_t address, int words, + uint8_t *data_out, size_t data_cap) +{ + uint8_t req[8]; + uint8_t resp[SPPRO_MAX_FRAME]; + const uint8_t *data; + size_t data_len, resp_len; + int rc; + + if (!t || !t->read || !t->write || words < 1 || words > SPPRO_MAX_WORDS) + return SPPRO_ERR_ARG; + resp_len = sppro_query_response_len(words); + if (resp_len > sizeof(resp)) + return SPPRO_ERR_BUFFER; + + if (sppro_build_query(req, sizeof(req), address, words) < 0) + return SPPRO_ERR_ARG; + rc = write_all(t, req, sizeof(req)); + if (rc != SPPRO_OK) return rc; + rc = read_exact(t, resp, resp_len); + if (rc != SPPRO_OK) return rc; + + rc = sppro_parse_query_response(resp, resp_len, address, words, &data, &data_len); + if (rc != SPPRO_OK) return rc; + if (data_out) { + if (data_cap < data_len) return SPPRO_ERR_BUFFER; + memcpy(data_out, data, data_len); + } + return (int)data_len; +} + +int sppro_session_write(const sppro_transport_t *t, uint32_t address, + const uint8_t *data, size_t data_len) +{ + uint8_t req[SPPRO_MAX_FRAME]; + uint8_t echo[SPPRO_MAX_FRAME]; + int req_len, rc; + + if (!t || !t->read || !t->write) + return SPPRO_ERR_ARG; + req_len = sppro_build_write(req, sizeof(req), address, data, data_len); + if (req_len < 0) return req_len; + + rc = write_all(t, req, (size_t)req_len); + if (rc != SPPRO_OK) return rc; + rc = read_exact(t, echo, (size_t)req_len); + if (rc != SPPRO_OK) return rc; + if (memcmp(req, echo, (size_t)req_len) != 0) + return SPPRO_ERR_ECHO; + return SPPRO_OK; +} + +int sppro_session_login(const sppro_transport_t *t, const char *password) +{ + uint8_t seed[16]; + uint8_t response[16]; + uint8_t status[2]; + int rc; + + rc = sppro_session_query(t, SPPRO_ADDR_LOGIN_HASH, 8, seed, sizeof(seed)); + if (rc < 0) return rc; + + sppro_login_response(seed, password, response); + + rc = sppro_session_write(t, SPPRO_ADDR_LOGIN_HASH, response, sizeof(response)); + if (rc != SPPRO_OK) return rc; + + rc = sppro_session_query(t, SPPRO_ADDR_LOGIN_STATUS, 1, status, sizeof(status)); + if (rc < 0) return rc; + if (sppro_u16(status) != 1) + return SPPRO_ERR_LOGIN; + return SPPRO_OK; +} + +int sppro_session_read_scales(const sppro_transport_t *t, sppro_scales_t *out) +{ + uint8_t data[12]; + int rc; + if (!out) return SPPRO_ERR_ARG; + rc = sppro_session_query(t, SPPRO_ADDR_SCALES, 6, data, sizeof(data)); + if (rc < 0) return rc; + return sppro_parse_scales(data, sizeof(data), out); +} + +int sppro_session_read(const sppro_transport_t *t, const sppro_reg_t *reg, + const sppro_scales_t *scales, double *value_out) +{ + uint8_t data[4]; + int words, rc; + if (!reg || !value_out) return SPPRO_ERR_ARG; + words = sppro_type_words(reg->type); + rc = sppro_session_query(t, reg->address, words, data, sizeof(data)); + if (rc < 0) return rc; + *value_out = sppro_decode(reg, data, scales); + return SPPRO_OK; +} diff --git a/tests/test_sppro.c b/tests/test_sppro.c new file mode 100644 index 0000000..015866d --- /dev/null +++ b/tests/test_sppro.c @@ -0,0 +1,185 @@ +/* + * test_sppro.c - host known-answer tests for the SP PRO parser. No hardware. + * make test + */ +#include "../src/sppro.h" +#include "../src/md5.h" + +#include +#include +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) do { \ + checks++; \ + if (!(cond)) { failures++; printf("FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); } \ +} while (0) + +static int near(double a, double b) { return fabs(a - b) < 1e-6; } + +static void hex(const uint8_t *p, size_t n, char *out) +{ + static const char *d = "0123456789abcdef"; + size_t i; + for (i = 0; i < n; i++) { out[i*2] = d[p[i] >> 4]; out[i*2+1] = d[p[i] & 0xf]; } + out[n*2] = 0; +} + +/* selpi documented example: read 1 word at 0xa000. */ +static const uint8_t QUERY_A000[] = { 0x51,0x00,0x00,0xa0,0x00,0x00,0x9d,0x4b }; +static const uint8_t RESPONSE_A000[] = { 0x51,0x00,0x00,0xa0,0x00,0x00,0x9d,0x4b,0x01,0x00,0xd8,0x19 }; + +static void test_crc(void) +{ + /* CRC over the 6-byte header equals the 0x4b9d carried little-endian. */ + CHECK(sppro_crc16(QUERY_A000, 6) == 0x4b9d, "crc header 0xa000"); + /* A full valid frame CRCs to zero. */ + CHECK(sppro_crc16(QUERY_A000, sizeof(QUERY_A000)) == 0, "crc whole query == 0"); + CHECK(sppro_crc16(RESPONSE_A000, sizeof(RESPONSE_A000)) == 0, "crc whole response == 0"); +} + +static void test_build_query(void) +{ + uint8_t buf[8]; + int n = sppro_build_query(buf, sizeof(buf), 0xa000, 1); + CHECK(n == 8, "build_query length"); + CHECK(memcmp(buf, QUERY_A000, 8) == 0, "build_query matches documented bytes"); + + CHECK(sppro_build_query(buf, sizeof(buf), 0xa000, 0) == SPPRO_ERR_ARG, "reject 0 words"); + CHECK(sppro_build_query(buf, sizeof(buf), 0xa000, 257) == SPPRO_ERR_ARG, "reject >256 words"); + CHECK(sppro_build_query(buf, 4, 0xa000, 1) == SPPRO_ERR_BUFFER, "reject small buffer"); +} + +static void test_parse_response(void) +{ + const uint8_t *data = NULL; + size_t data_len = 0; + int rc = sppro_parse_query_response(RESPONSE_A000, sizeof(RESPONSE_A000), + 0xa000, 1, &data, &data_len); + CHECK(rc == SPPRO_OK, "parse response ok"); + CHECK(data_len == 2, "data length 2"); + CHECK(data && sppro_u16(data) == 1, "decoded word == 1"); + + /* Corrupt one byte -> CRC failure. */ + uint8_t bad[sizeof(RESPONSE_A000)]; + memcpy(bad, RESPONSE_A000, sizeof(bad)); + bad[9] ^= 0xff; + CHECK(sppro_parse_query_response(bad, sizeof(bad), 0xa000, 1, NULL, NULL) == SPPRO_ERR_CRC, + "detect corrupted response"); + /* Wrong expected address -> echo mismatch. */ + CHECK(sppro_parse_query_response(RESPONSE_A000, sizeof(RESPONSE_A000), 0xb000, 1, NULL, NULL) + == SPPRO_ERR_ECHO, "detect wrong address echo"); +} + +static void test_build_write(void) +{ + uint8_t data[16]; + uint8_t frame[SPPRO_MAX_FRAME]; + size_t i; + int n; + for (i = 0; i < sizeof(data); i++) data[i] = (uint8_t)(i + 1); + n = sppro_build_write(frame, sizeof(frame), 0x1f0000, data, sizeof(data)); + CHECK(n == (int)(8 + 16 + 2), "write frame length"); + CHECK(frame[0] == 'W' && frame[1] == 7, "write header type and word count"); + CHECK(sppro_crc16(frame, 6) == sppro_u16(frame + 6), "write inner header crc"); + CHECK(sppro_crc16(frame, (size_t)n) == 0, "write whole frame crc == 0"); + CHECK(memcmp(frame + 8, data, sizeof(data)) == 0, "write payload copied"); + + CHECK(sppro_build_write(frame, sizeof(frame), 0, data, 3) == SPPRO_ERR_ARG, "reject odd length"); +} + +static void test_md5(void) +{ + unsigned char d[16]; + char s[33]; + sppro_md5("", 0, d); + hex(d, 16, s); + CHECK(strcmp(s, "d41d8cd98f00b204e9800998ecf8427e") == 0, "md5 empty string"); + sppro_md5("abc", 3, d); + hex(d, 16, s); + CHECK(strcmp(s, "900150983cd24fb0d6963f7d28e17f72") == 0, "md5 abc"); +} + +static void test_login_response(void) +{ + /* Independently reproduce: md5(seed + password padded to 32 with spaces), + * then swap adjacent byte pairs. Validates buffer layout and the swap. */ + uint8_t seed[16]; + const char *pw = "secret"; + uint8_t out[16], expect[16]; + unsigned char digest[16]; + uint8_t buf[48]; + size_t i; + + for (i = 0; i < 16; i++) seed[i] = (uint8_t)(0x10 + i); + memcpy(buf, seed, 16); + memcpy(buf + 16, pw, strlen(pw)); + for (i = 16 + strlen(pw); i < 48; i++) buf[i] = ' '; + sppro_md5(buf, 48, digest); + for (i = 0; i < 16; i += 2) { expect[i] = digest[i+1]; expect[i+1] = digest[i]; } + + sppro_login_response(seed, pw, out); + CHECK(memcmp(out, expect, 16) == 0, "login response md5+swap"); +} + +static void test_conversions(void) +{ + sppro_scales_t s; + s.ac_volts = 32768; s.ac_current = 800; + s.dc_volts = 32768; s.dc_current = 100; + s.temperature = 32768; s.internal_voltages = 32768; + + CHECK(near(sppro_convert(SPPRO_C_DC_V, 480, &s), 48.0), "dc_v 480 -> 48.0V"); + CHECK(near(sppro_convert(SPPRO_C_PERCENT, 12800, &s), 50.0), "percent 12800 -> 50%"); + CHECK(near(sppro_convert(SPPRO_C_TEMPERATURE, 25, &s), 25.0), "temperature 25 -> 25C"); + CHECK(near(sppro_convert(SPPRO_C_AC_W, 1500, &s), 1500.0), "ac_w with unity scale"); + /* dc_w: raw*dcv*dci/(32768*100) = raw*32768*100/(32768*100) = raw */ + CHECK(near(sppro_convert(SPPRO_C_DC_W, -250, &s), -250.0), "dc_w signed"); + + CHECK(strcmp(sppro_shunt_name(1), "Solar") == 0, "shunt name 1 = Solar"); + CHECK(strcmp(sppro_shunt_name(99), "Error") == 0, "shunt name out of range"); +} + +static void test_decode_via_reg(void) +{ + sppro_scales_t s; + const sppro_reg_t *reg; + uint8_t data[2]; + s.ac_volts = s.dc_volts = s.temperature = s.internal_voltages = 32768; + s.ac_current = 800; s.dc_current = 100; + + reg = sppro_reg_by_name("BatteryVolts"); + CHECK(reg != NULL, "lookup BatteryVolts"); + data[0] = 0xe0; data[1] = 0x01; /* 0x01e0 = 480 */ + CHECK(near(sppro_decode(reg, data, &s), 48.0), "decode BatteryVolts -> 48.0V"); + + CHECK(sppro_reg_by_name("DoesNotExist") == NULL, "unknown register name"); + CHECK(sppro_type_words(SPPRO_T_U32) == 2 && sppro_type_words(SPPRO_T_U16) == 1, "type words"); +} + +static void test_parse_scales(void) +{ + uint8_t d[12] = { 0,0x80, 0x20,0x03, 0,0x80, 0x64,0, 0,0x80, 0,0x80 }; + sppro_scales_t s; + CHECK(sppro_parse_scales(d, sizeof(d), &s) == SPPRO_OK, "parse scales ok"); + CHECK(s.ac_volts == 0x8000 && s.ac_current == 0x0320 && s.dc_current == 0x0064, + "scales fields little-endian"); +} + +int main(void) +{ + test_crc(); + test_build_query(); + test_parse_response(); + test_build_write(); + test_md5(); + test_login_response(); + test_conversions(); + test_decode_via_reg(); + test_parse_scales(); + + printf("\n%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +}