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
+36
View File
@@ -0,0 +1,36 @@
# 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 -I../src
LDFLAGS ?=
SRCDIR := ../src
SRC := $(SRCDIR)/sppro.c $(SRCDIR)/md5.c
HDR := $(SRCDIR)/sppro.h $(SRCDIR)/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)
+143
View File
@@ -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 "sppro.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>
/* ----- 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;
}
+185
View File
@@ -0,0 +1,185 @@
/*
* test_sppro.c - host known-answer tests for the SP PRO parser. No hardware.
* make test
*/
#include "sppro.h"
#include "md5.h"
#include <stdio.h>
#include <string.h>
#include <math.h>
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;
}