This commit is contained in:
2026-06-16 04:59:02 +10:00
parent e139079a3c
commit 436d452deb
14 changed files with 399 additions and 135 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Build output
/build/
build/
*.o
*.a
*.elf
+32
View File
@@ -0,0 +1,32 @@
MIT License
Copyright (c) 2026 Scott P
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Third-party / attribution notes:
* src/md5.c, src/md5.h - MD5 implementation by Alexander Peslyak (Solar
Designer), placed in the public domain. See the notice in those files.
* The SP PRO serial protocol implementation (CRC table, framing, login flow,
register map and scaling) is ported from the open-source project
neerolyte/selpi (https://github.com/neerolyte/selpi).
+58 -21
View File
@@ -10,38 +10,69 @@ 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
This repo is 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), packaged as a **PlatformIO / Arduino library** with an optional thin C++
wrapper. 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,
src/ sppro.h / sppro.c portable C 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)
Sppro.h optional header-only C++/Arduino wrapper (any Stream)
examples/ Esp32SerialMonitor/ ESP32 example (.ino + platformio.ini)
docs/ PROTOCOL.md wire protocol, CRC, login, register map + scaling
HARDWARE.md RS-232 pinout and MAX3232 -> ESP32 wiring
extras/ host/main.c Linux serial-console dashboard (termios)
tests/test_sppro.c host known-answer tests (no hardware)
Makefile host build (test / console)
library.json, library.properties, keywords.txt PlatformIO + Arduino manifests
```
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.
The core (`src/sppro.*`) 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, an MCU,
or anything with a serial port. Only `src/` and `examples/` are part of the published
library; `extras/` is development tooling (Arduino ignores it).
## Build & test
## Use on Arduino / ESP32 (PlatformIO or Arduino IDE)
Add the library (`lib_deps = SPPro` in PlatformIO once published, or *Sketch → Include
Library* in the Arduino IDE), then use the `Sppro` wrapper, which works over **any**
Arduino `Stream` (HardwareSerial, SoftwareSerial, WiFiClient, …):
```cpp
#include <Sppro.h>
Sppro sp(Serial2); // wraps any Stream
void setup() {
Serial2.begin(57600, SERIAL_8N1, 16, 17); // via a MAX3232 level shifter
sp.login(""); // "" = no serial password
sp.readScales();
}
void loop() {
Serial.println(sp.value("BatteryVolts")); // scaled value, or NAN on error
delay(5000);
}
```
A complete ESP32 example is in
[`examples/Esp32SerialMonitor/`](examples/Esp32SerialMonitor); build it standalone with
`cd examples/Esp32SerialMonitor && pio run` (it links the parent library via
`lib_deps = symlink://../..`). See [docs/HARDWARE.md](docs/HARDWARE.md) — the SP PRO port
is RS-232 (±12 V) and **needs a MAX3232 level shifter**; never wire it straight to a GPIO.
## Build & test on a host (no hardware)
```sh
make test # build and run the host unit tests (no hardware needed)
cd extras
make test # build and run the host unit tests
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:
Then, with a real SP PRO + USB-RS232 adapter:
```sh
./build/sppro-console /dev/ttyUSB0 [serial_password] [interval_seconds]
@@ -52,7 +83,7 @@ voltage, state of charge, load/solar/generator power, and energy totals. The ser
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
## Use the pure C core (non-Arduino: ESP-IDF, STM32, Linux, Zephyr …)
```c
#include "sppro.h"
@@ -68,15 +99,21 @@ 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`,
`extras/host/main.c` is a full worked example of the two callbacks (termios). 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.
- Verified by host unit tests (`cd extras && make test`): CRC against selpi's documented
`0xa000` frame, MD5 known-answers, frame round-trips, and the scaling formulas. The
ESP32 example + C++ wrapper are verified to compile/link via PlatformIO. **Not yet
verified against physical hardware** — confirm decoded values against the inverter's own
display.
- On 8-bit AVR, `double` is 32-bit float, so very large `Wh` accumulators lose some
precision (fine for display). Use the 32-bit-int register types directly if you need
exact counts.
- 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.
+1 -1
View File
@@ -59,7 +59,7 @@ 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`.
sketch if so.) See `examples/Esp32SerialMonitor/`.
## Quick bring-up checks
-105
View File
@@ -1,105 +0,0 @@
/*
* 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);
}
@@ -0,0 +1,76 @@
/*
* Esp32SerialMonitor - read a Selectronic SP PRO from an ESP32 and print a
* dashboard to the USB serial monitor, using the thin Sppro C++ wrapper.
*
* The SP PRO serial port is RS-232 (+/-12V) - you MUST use a level shifter
* (MAX3232); do not wire it straight to a GPIO. See docs/HARDWARE.md:
*
* 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)
*
* Build with PlatformIO (this folder has its own platformio.ini):
* cd examples/Esp32SerialMonitor && pio run
* or open the .ino in the Arduino IDE after installing this library.
*/
#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 *PASSWORD = ""; /* inverter serial-port password ("" if none) */
/* Sppro wraps any Arduino Stream; here it is UART2. */
Sppro sp(Serial2);
bool ready = false;
static bool connectSppro() {
if (sp.login(PASSWORD) != SPPRO_OK) {
Serial.println("login failed - check wiring/password");
return false;
}
if (sp.readScales() != 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);
if (!reg) return;
if (reg->conv == SPPRO_C_SHUNT_NAME) {
Serial.printf("%-26s %12s\n", reg->description, sp.name(name));
} else {
double v;
if (sp.read(name, v) != SPPRO_OK)
Serial.printf("%-26s (read error)\n", reg->description);
else
Serial.printf("%-26s %12.2f %s\n", reg->description, v, reg->units);
}
}
void setup() {
Serial.begin(115200);
Serial2.begin(SPPRO_BAUD, SERIAL_8N1, PIN_RX, PIN_TX);
delay(200);
ready = connectSppro();
}
void loop() {
if (!ready) {
delay(2000);
ready = connectSppro();
return;
}
Serial.println("\n== Selectronic SP PRO ==");
show("BatteryVolts");
show("BattSocPercent");
show("BatteryTemperature");
show("DCBatteryPower");
show("LoadAcPower");
show("CombinedKacoAcPowerHiRes");
show("ACGeneratorPower");
delay(5000);
}
@@ -0,0 +1,16 @@
; Build this example standalone: cd here && pio run
; It pulls in the parent SPPro library via a symlink to the repo root
; (which holds library.json). symlink:// is the modern replacement for the
; deprecated lib_extra_dirs.
;
; src_dir = . keeps the .ino at the example root (so the Arduino IDE sees it as
; a normal example) while still letting `pio run` build it from this folder.
[platformio]
src_dir = .
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps = symlink://../..
+7 -4
View File
@@ -1,11 +1,14 @@
# SPPro - portable C parser for Selectronic SP PRO serial data.
# SPPro - host build for the portable C parser (dev tooling, lives in extras/).
# The library itself is ../src; these targets are not part of the published
# Arduino/PlatformIO library.
CC ?= cc
CFLAGS ?= -std=c99 -Wall -Wextra -Wpedantic -O2
CFLAGS ?= -std=c99 -Wall -Wextra -Wpedantic -O2 -I../src
LDFLAGS ?=
SRC := src/sppro.c src/md5.c
HDR := src/sppro.h src/md5.h
SRCDIR := ../src
SRC := $(SRCDIR)/sppro.c $(SRCDIR)/md5.c
HDR := $(SRCDIR)/sppro.h $(SRCDIR)/md5.h
BUILD := build
+1 -1
View File
@@ -13,7 +13,7 @@
*/
#define _DEFAULT_SOURCE /* cfmakeraw, CRTSCTS, sleep() under -std=c99 */
#include "../src/sppro.h"
#include "sppro.h"
#include <errno.h>
#include <fcntl.h>
@@ -2,8 +2,8 @@
* 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 "sppro.h"
#include "md5.h"
#include <stdio.h>
#include <string.h>
+49
View File
@@ -0,0 +1,49 @@
#######################################
# Syntax coloring for the SPPro library
#######################################
#######################################
# Datatypes (KEYWORD1)
#######################################
Sppro KEYWORD1
sppro_transport_t KEYWORD1
sppro_scales_t KEYWORD1
sppro_reg_t KEYWORD1
sppro_status_t KEYWORD1
sppro_type_t KEYWORD1
sppro_conv_t KEYWORD1
#######################################
# Methods and Functions (KEYWORD2)
#######################################
login KEYWORD2
readScales KEYWORD2
scales KEYWORD2
read KEYWORD2
value KEYWORD2
name KEYWORD2
sppro_crc16 KEYWORD2
sppro_build_query KEYWORD2
sppro_build_write KEYWORD2
sppro_parse_query_response KEYWORD2
sppro_decode KEYWORD2
sppro_convert KEYWORD2
sppro_reg_by_name KEYWORD2
sppro_shunt_name KEYWORD2
sppro_login_response KEYWORD2
sppro_session_login KEYWORD2
sppro_session_query KEYWORD2
sppro_session_write KEYWORD2
sppro_session_read KEYWORD2
sppro_session_read_scales KEYWORD2
#######################################
# Constants (LITERAL1)
#######################################
SPPRO_BAUD LITERAL1
SPPRO_OK LITERAL1
SPPRO_ERR_UNKNOWN LITERAL1
SPPRO_C_SHUNT_NAME LITERAL1
+36
View File
@@ -0,0 +1,36 @@
{
"name": "SPPro",
"version": "0.1.0",
"description": "Portable C parser for the Selectronic SP PRO inverter serial protocol (CRC, frame build/parse, MD5 login, register decoding + scaling), with an optional thin Arduino C++ wrapper that works over any Stream (HardwareSerial, SoftwareSerial, WiFiClient).",
"keywords": "selectronic, sp pro, inverter, solar, battery, rs232, serial, energy",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/scottp/SPPro.git"
},
"authors": [
{
"name": "Scott P",
"maintainer": true
}
],
"frameworks": "*",
"platforms": "*",
"headers": ["sppro.h", "Sppro.h"],
"examples": [
{
"name": "Esp32SerialMonitor",
"base": "examples/Esp32SerialMonitor",
"files": ["Esp32SerialMonitor.ino", "platformio.ini"]
}
],
"export": {
"exclude": [
"extras",
"build",
".pio",
"**/build",
"**/.pio"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
name=SPPro
version=0.1.0
author=Scott P
maintainer=Scott P
sentence=Read and decode Selectronic SP PRO inverter data over its serial port.
paragraph=Portable C parser for the SP PRO serial protocol (Kermit CRC, Q/W frame build/parse, MD5 challenge/response login, register decoding and scaling). Includes an optional thin C++ wrapper around any Arduino Stream (HardwareSerial, SoftwareSerial, WiFiClient/EthernetClient). Ported from the open-source selpi project; needs an RS-232 level shifter (e.g. MAX3232).
category=Communication
url=https://github.com/scottp/SPPro
architectures=*
license=MIT
includes=Sppro.h
+109
View File
@@ -0,0 +1,109 @@
/*
* Sppro.h - optional thin C++/Arduino wrapper around the portable C core.
*
* Header-only and guarded by ARDUINO, so it compiles to nothing on non-Arduino
* platforms (where you use the C API in sppro.h directly with your own
* sppro_transport_t callbacks). The class wraps any Arduino Stream, so it works
* over HardwareSerial (Serial1/Serial2), SoftwareSerial, or a networked client
* (WiFiClient/EthernetClient) without any per-transport code.
*
* #include <Sppro.h>
* Sppro sp(Serial2);
* void setup() { Serial2.begin(57600, SERIAL_8N1, 16, 17);
* sp.login(""); sp.readScales(); }
* void loop() { Serial.println(sp.value("BatteryVolts")); }
*
* Note: on 8-bit AVR `double` is 32-bit float, so very large Wh accumulators
* lose some precision (fine for display).
*/
#ifndef SPPRO_CPP_H
#define SPPRO_CPP_H
#ifdef ARDUINO
#include <Arduino.h>
#include <math.h>
extern "C" {
#include "sppro.h"
}
class Sppro {
public:
/* Wrap any Arduino Stream (HardwareSerial, SoftwareSerial, Client, ...).
* Configure the stream's baud/pins yourself before calling login(). */
explicit Sppro(Stream &io, unsigned long readTimeoutMs = 200)
: _io(io), _timeout(readTimeoutMs)
{
_t.read = &Sppro::readCb;
_t.write = &Sppro::writeCb;
_t.ctx = this;
}
/* MD5 challenge/response login. pw is the inverter serial-port password
* ("" if none). Returns SPPRO_OK or a negative sppro_status_t. */
int login(const char *pw = "") { return sppro_session_login(&_t, pw); }
/* Read the device scale factors once after login; cached for read()/value(). */
int readScales() { return sppro_session_read_scales(&_t, &_scales); }
const sppro_scales_t &scales() const { return _scales; }
const sppro_transport_t &transport() const { return _t; }
/* Read a register by its selpi name into out. Returns SPPRO_OK,
* SPPRO_ERR_UNKNOWN for a bad name, or a negative I/O error. */
int read(const char *name, double &out)
{
const sppro_reg_t *reg = sppro_reg_by_name(name);
if (!reg) return SPPRO_ERR_UNKNOWN;
return sppro_session_read(&_t, reg, &_scales, &out);
}
/* Convenience: returns the scaled value, or NAN on any error. */
double value(const char *name)
{
double v;
return read(name, v) == SPPRO_OK ? v : NAN;
}
/* For SPPRO_C_SHUNT_NAME registers: returns the label, or "Error". */
const char *name(const char *reg_name)
{
double v;
if (read(reg_name, v) != SPPRO_OK) return "Error";
return sppro_shunt_name((int)v);
}
private:
static int readCb(void *ctx, uint8_t *buf, size_t len)
{
Sppro *self = static_cast<Sppro *>(ctx);
Stream &io = self->_io;
size_t n = 0;
unsigned long start = millis();
while (n < len && (millis() - start) < self->_timeout) {
while (io.available() && n < len) {
int c = io.read();
if (c < 0) break;
buf[n++] = (uint8_t)c;
start = millis();
}
if (n == 0) delay(1);
else break;
}
return (int)n; /* 0 on timeout */
}
static int writeCb(void *ctx, const uint8_t *buf, size_t len)
{
return (int)static_cast<Sppro *>(ctx)->_io.write(buf, len);
}
Stream &_io;
unsigned long _timeout;
sppro_transport_t _t;
sppro_scales_t _scales;
};
#endif /* ARDUINO */
#endif /* SPPRO_CPP_H */