First version getting ready to release
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Build output
|
||||||
|
/build/
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
|
*.elf
|
||||||
|
*.bin
|
||||||
|
|
||||||
|
# PlatformIO (ESP32)
|
||||||
|
.pio/
|
||||||
|
.pioenvs/
|
||||||
|
.piolibdeps/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -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)
|
||||||
@@ -10,6 +10,131 @@ PyAware seems to already decode Serial Data. Look at porting that to portable C
|
|||||||
* Parse Serial Data
|
* Parse Serial Data
|
||||||
* A simple C parser, portable to allow parsing
|
* 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
|
## See Also
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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: <exact echo of the request>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+143
@@ -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 <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;
|
||||||
|
}
|
||||||
+388
@@ -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 <string.h>
|
||||||
|
|
||||||
|
/* ----- 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;
|
||||||
|
}
|
||||||
@@ -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 <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