Wrapper
This commit is contained in:
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user