From 2d9660e6c52b8cc407ad82a30a46b026a8a61abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20=C3=96ster?= Date: Wed, 6 Aug 2025 15:31:45 +0300 Subject: [PATCH 1/3] Add Sol-ark files --- Software/src/inverter/SOL-ARK-LV-CAN.cpp | 125 +++++++++++++++++++++++ Software/src/inverter/SOL-ARK-LV-CAN.h | 55 ++++++++++ 2 files changed, 180 insertions(+) create mode 100644 Software/src/inverter/SOL-ARK-LV-CAN.cpp create mode 100644 Software/src/inverter/SOL-ARK-LV-CAN.h diff --git a/Software/src/inverter/SOL-ARK-LV-CAN.cpp b/Software/src/inverter/SOL-ARK-LV-CAN.cpp new file mode 100644 index 00000000..ea6e5b1d --- /dev/null +++ b/Software/src/inverter/SOL-ARK-LV-CAN.cpp @@ -0,0 +1,125 @@ +#include "SOL-ARK-LV-CAN.h" +#include "../communication/can/comm_can.h" +#include "../datalayer/datalayer.h" + +/* Sol-Ark v1.3 protocol +The Sol-Ark inverters only recognize standard CAN Bus frames containing 8 bytes of data. +CAN FD with 64 data bytes per frame is NOT supported. +Communication Rate: 500 kbps +Data Endianness: Little Endian (least significate byte is at left end of multi or 2-byte values) +Transmission Cycle Rate: BMS full data set here shall be transmitted to the inverter once every second. +BMS sends first message with this full register set. Inverter Heartbeat Response: Each time the inverter +correctly receives data, it will respond with CAN ID 0x305 containing “00 00 00 00 00 00 00 00” as data.*/ + +void SolArkLvInverter::update_values() { + + // Set "Charge voltage limit" to battery max value OR user supplied value + uint16_t charge_voltage_dV = datalayer.battery.info.max_design_voltage_dV; + if (datalayer.battery.settings.user_set_voltage_limits_active) + charge_voltage_dV = datalayer.battery.settings.max_user_set_charge_voltage_dV; + if (charge_voltage_dV > datalayer.battery.info.max_design_voltage_dV) + charge_voltage_dV = datalayer.battery.info.max_design_voltage_dV; + SOLARK_351.data.u8[0] = charge_voltage_dV & 0xff; + SOLARK_351.data.u8[1] = charge_voltage_dV >> 8; + //Rest of setpoints in deci-units + SOLARK_351.data.u8[2] = datalayer.battery.status.max_charge_current_dA & 0xff; + SOLARK_351.data.u8[3] = datalayer.battery.status.max_charge_current_dA >> 8; + SOLARK_351.data.u8[4] = datalayer.battery.status.max_discharge_current_dA & 0xff; + SOLARK_351.data.u8[5] = datalayer.battery.status.max_discharge_current_dA >> 8; + SOLARK_351.data.u8[6] = datalayer.battery.info.min_design_voltage_dV & 0xff; + SOLARK_351.data.u8[7] = datalayer.battery.info.min_design_voltage_dV >> 8; + + SOLARK_355.data.u8[0] = (datalayer.battery.status.reported_soc / 100) & 0xff; + SOLARK_355.data.u8[1] = (datalayer.battery.status.reported_soc / 100) >> 8; + SOLARK_355.data.u8[2] = (datalayer.battery.status.soh_pptt / 100) & 0xff; + SOLARK_355.data.u8[3] = (datalayer.battery.status.soh_pptt / 100) >> 8; + + int16_t average_temperature = + (datalayer.battery.status.temperature_min_dC + datalayer.battery.status.temperature_max_dC) / 2; + SOLARK_356.data.u8[0] = datalayer.battery.status.voltage_dV & 0xff; + SOLARK_356.data.u8[1] = datalayer.battery.status.voltage_dV >> 8; + SOLARK_356.data.u8[2] = datalayer.battery.status.current_dA & 0xff; + SOLARK_356.data.u8[3] = datalayer.battery.status.current_dA >> 8; + SOLARK_356.data.u8[4] = average_temperature & 0xff; + SOLARK_356.data.u8[5] = average_temperature >> 8; + + // initialize all errors and warnings to 0 + SOLARK_359.data.u8[0] = 0x00; //Protection byte 1 + SOLARK_359.data.u8[1] = 0x00; //Protection byte 2 + SOLARK_359.data.u8[2] = 0x00; // Alarm byte 1 + SOLARK_359.data.u8[3] = 0x00; // Alarm byte 2 + SOLARK_359.data.u8[4] = MODULE_NUMBER; + SOLARK_359.data.u8[5] = 0x50; //P + SOLARK_359.data.u8[6] = 0x4E; //N + SOLARK_359.data.u8[7] = 0x00; //Unused, should be 00 + + // Protection Byte 1 Bitfield: (If a bit is set, one of these caused batt self-protection mode) + if (datalayer.battery.status.current_dA >= (datalayer.battery.status.max_discharge_current_dA + 50)) + SOLARK_359.data.u8[0] |= 0x80; + if (datalayer.battery.status.temperature_min_dC <= BATTERY_MINTEMPERATURE) + SOLARK_359.data.u8[0] |= 0x10; + if (datalayer.battery.status.temperature_max_dC >= BATTERY_MAXTEMPERATURE) + SOLARK_359.data.u8[0] |= 0x0C; + if (datalayer.battery.status.voltage_dV <= datalayer.battery.info.min_design_voltage_dV) + SOLARK_359.data.u8[0] |= 0x04; + if (datalayer.battery.status.bms_status == FAULT) + SOLARK_359.data.u8[1] |= 0x80; + if (datalayer.battery.status.current_dA <= -1 * datalayer.battery.status.max_charge_current_dA) + SOLARK_359.data.u8[1] |= 0x01; + + // WARNINGS (using same rules as errors but reporting earlier) + if (datalayer.battery.status.current_dA >= datalayer.battery.status.max_discharge_current_dA * WARNINGS_PERCENT / 100) + PYLON_359.data.u8[2] |= 0x80; + if (datalayer.battery.status.temperature_min_dC <= + warning_threshold_of_min(BATTERY_MINTEMPERATURE, BATTERY_MAXTEMPERATURE)) + PYLON_359.data.u8[2] |= 0x10; + if (datalayer.battery.status.temperature_max_dC >= BATTERY_MAXTEMPERATURE * WARNINGS_PERCENT / 100) + PYLON_359.data.u8[2] |= 0x0C; + if (datalayer.battery.status.voltage_dV <= warning_threshold_of_min(datalayer.battery.info.min_design_voltage_dV, + datalayer.battery.info.max_design_voltage_dV)) + PYLON_359.data.u8[2] |= 0x04; + // we never set PYLON_359.data.u8[3] |= 0x80 called "BMS internal" + if (datalayer.battery.status.current_dA <= + -1 * datalayer.battery.status.max_charge_current_dA * WARNINGS_PERCENT / 100) + PYLON_359.data.u8[3] |= 0x01; + + PYLON_35C.data.u8[0] = 0xC0; // enable charging and discharging + if (datalayer.battery.status.bms_status == FAULT) + PYLON_35C.data.u8[0] = 0x00; // disable all + else if (datalayer.battery.settings.user_set_voltage_limits_active && + datalayer.battery.status.voltage_dV > datalayer.battery.settings.max_user_set_charge_voltage_dV) + PYLON_35C.data.u8[0] = 0x40; // only allow discharging + else if (datalayer.battery.settings.user_set_voltage_limits_active && + datalayer.battery.status.voltage_dV < datalayer.battery.settings.max_user_set_discharge_voltage_dV) + PYLON_35C.data.u8[0] = 0xA0; // enable charing, set charge immediately + else if (datalayer.battery.status.real_soc <= datalayer.battery.settings.min_percentage) + PYLON_35C.data.u8[0] = 0xA0; // enable charing, set charge immediately + else if (datalayer.battery.status.real_soc >= datalayer.battery.settings.max_percentage) + PYLON_35C.data.u8[0] = 0x40; // enable discharging only + + // PYLON_35E is pre-filled with the manufacturer name +} + +void SolArkLvInverter::map_can_frame_to_variable(CAN_frame rx_frame) { + switch (rx_frame.ID) { + case 0x305: //Message originating from inverter, signalling that data rec OK + datalayer.system.status.CAN_inverter_still_alive = CAN_STILL_ALIVE; + break; + default: + break; + } +} + +void SolArkLvInverter::transmit_can(unsigned long currentMillis) { + + if (currentMillis - previousMillis1000ms >= INTERVAL_1_S) { + previousMillis1000ms = currentMillis; + + transmit_can_frame(&PYLON_351); + transmit_can_frame(&PYLON_355); + transmit_can_frame(&PYLON_356); + transmit_can_frame(&PYLON_359); + transmit_can_frame(&PYLON_35C); + transmit_can_frame(&PYLON_35E); + } +} diff --git a/Software/src/inverter/SOL-ARK-LV-CAN.h b/Software/src/inverter/SOL-ARK-LV-CAN.h new file mode 100644 index 00000000..98b2cf9f --- /dev/null +++ b/Software/src/inverter/SOL-ARK-LV-CAN.h @@ -0,0 +1,55 @@ +#ifndef SOL_ARK_LV_CAN_H +#define SOL_ARK_LV_CAN_H + +#include "CanInverterProtocol.h" + +#ifdef SOL_ARK_LV_CAN +#define SELECTED_INVERTER_CLASS SolArkLvInverter +#endif + +class SolArkLvInverter : public CanInverterProtocol { + public: + const char* name() override { return Name; } + void update_values(); + void transmit_can(unsigned long currentMillis); + void map_can_frame_to_variable(CAN_frame rx_frame); + static constexpr const char* Name = "Sol-Ark LV protocol over CAN bus"; + + private: + unsigned long previousMillis1000ms = 0; + + const uint8_t MODULE_NUMBER = 1; //8-bit integer representing quantity of parallel connected batteries + + CAN_frame SOLARK_359 = {.FD = false, + .ext_ID = false, + .DLC = 8, + .ID = 0x359, + .data = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}; + CAN_frame SOLARK_351 = {.FD = false, + .ext_ID = false, + .DLC = 8, + .ID = 0x351, + .data = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}; + CAN_frame SOLARK_355 = {.FD = false, + .ext_ID = false, + .DLC = 8, + .ID = 0x355, + .data = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}; + CAN_frame SOLARK_356 = {.FD = false, + .ext_ID = false, + .DLC = 8, + .ID = 0x356, + .data = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}; + CAN_frame SOLARK_35C = {.FD = false, + .ext_ID = false, + .DLC = 8, + .ID = 0x35C, + .data = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}; + CAN_frame SOLARK_35E = {.FD = false, + .ext_ID = false, + .DLC = 8, + .ID = 0x35E, //BAT-EMU + .data = {0x42, 0x41, 0x54, 0x2D, 0x45, 0x4D, 0x55, 0x20}}; +}; + +#endif From 83d2b2a754c8dd2e57c2f9f9f71c2701f0f218ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20=C3=96ster?= Date: Thu, 7 Aug 2025 19:21:53 +0300 Subject: [PATCH 2/3] Add configuration option for Sol-Ark --- Software/USER_SETTINGS.h | 1 + 1 file changed, 1 insertion(+) diff --git a/Software/USER_SETTINGS.h b/Software/USER_SETTINGS.h index b806ef90..59973c2a 100644 --- a/Software/USER_SETTINGS.h +++ b/Software/USER_SETTINGS.h @@ -71,6 +71,7 @@ //#define SOFAR_CAN //Enable this line to emulate a "Sofar Energy Storage Inverter High Voltage BMS General Protocol (Extended Frame)" over CAN bus //#define SOLAX_CAN //Enable this line to emulate a "SolaX Triple Power LFP" over CAN bus //#define SOLXPOW_CAN //Enable this line to emulate a "Solxpow compatible battery" over CAN bus +//#define SOL_ARK_LV_CAN //Enable this line to emulate a "Sol-Ark compatible LV battery" over CAN bus //#define SUNGROW_CAN //Enable this line to emulate a "Sungrow SBR064" over CAN bus /* Select hardware used for Battery-Emulator */ From 16ec6791e328fa4ff1542d79459d3a4bb40e0baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20=C3=96ster?= Date: Thu, 7 Aug 2025 19:24:56 +0300 Subject: [PATCH 3/3] Finalize SolArkLV protocol --- Software/src/inverter/INVERTERS.cpp | 7 ++++ Software/src/inverter/INVERTERS.h | 1 + Software/src/inverter/InverterProtocol.h | 1 + Software/src/inverter/SOL-ARK-LV-CAN.cpp | 41 ++++++++---------------- 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/Software/src/inverter/INVERTERS.cpp b/Software/src/inverter/INVERTERS.cpp index 92c16de3..aa762e75 100644 --- a/Software/src/inverter/INVERTERS.cpp +++ b/Software/src/inverter/INVERTERS.cpp @@ -76,6 +76,9 @@ extern const char* name_for_inverter_type(InverterProtocolType type) { case InverterProtocolType::Solxpow: return SolxpowInverter::Name; + case InverterProtocolType::SolArkLv: + return SolArkLvInverter::Name; + case InverterProtocolType::Sungrow: return SungrowInverter::Name; } @@ -169,6 +172,10 @@ bool setup_inverter() { inverter = new SolxpowInverter(); break; + case InverterProtocolType::SolArkLv: + inverter = new SolArkLvInverter(); + break; + case InverterProtocolType::Sungrow: inverter = new SungrowInverter(); break; diff --git a/Software/src/inverter/INVERTERS.h b/Software/src/inverter/INVERTERS.h index 981c2b16..c706c8a9 100644 --- a/Software/src/inverter/INVERTERS.h +++ b/Software/src/inverter/INVERTERS.h @@ -28,6 +28,7 @@ extern InverterProtocol* inverter; #include "SMA-LV-CAN.h" #include "SMA-TRIPOWER-CAN.h" #include "SOFAR-CAN.h" +#include "SOL-ARK-LV-CAN.h" #include "SOLAX-CAN.h" #include "SOLXPOW-CAN.h" #include "SUNGROW-CAN.h" diff --git a/Software/src/inverter/InverterProtocol.h b/Software/src/inverter/InverterProtocol.h index c7f554ca..3e48ea70 100644 --- a/Software/src/inverter/InverterProtocol.h +++ b/Software/src/inverter/InverterProtocol.h @@ -24,6 +24,7 @@ enum class InverterProtocolType { Sofar, Solax, Solxpow, + SolArkLv, Sungrow, Highest }; diff --git a/Software/src/inverter/SOL-ARK-LV-CAN.cpp b/Software/src/inverter/SOL-ARK-LV-CAN.cpp index ea6e5b1d..c8c8fb55 100644 --- a/Software/src/inverter/SOL-ARK-LV-CAN.cpp +++ b/Software/src/inverter/SOL-ARK-LV-CAN.cpp @@ -68,36 +68,23 @@ void SolArkLvInverter::update_values() { SOLARK_359.data.u8[1] |= 0x01; // WARNINGS (using same rules as errors but reporting earlier) - if (datalayer.battery.status.current_dA >= datalayer.battery.status.max_discharge_current_dA * WARNINGS_PERCENT / 100) - PYLON_359.data.u8[2] |= 0x80; - if (datalayer.battery.status.temperature_min_dC <= - warning_threshold_of_min(BATTERY_MINTEMPERATURE, BATTERY_MAXTEMPERATURE)) - PYLON_359.data.u8[2] |= 0x10; - if (datalayer.battery.status.temperature_max_dC >= BATTERY_MAXTEMPERATURE * WARNINGS_PERCENT / 100) - PYLON_359.data.u8[2] |= 0x0C; - if (datalayer.battery.status.voltage_dV <= warning_threshold_of_min(datalayer.battery.info.min_design_voltage_dV, - datalayer.battery.info.max_design_voltage_dV)) - PYLON_359.data.u8[2] |= 0x04; - // we never set PYLON_359.data.u8[3] |= 0x80 called "BMS internal" - if (datalayer.battery.status.current_dA <= - -1 * datalayer.battery.status.max_charge_current_dA * WARNINGS_PERCENT / 100) - PYLON_359.data.u8[3] |= 0x01; + // TODO: Not enabled in this integration yet. See Pylon protocol for example integration - PYLON_35C.data.u8[0] = 0xC0; // enable charging and discharging + SOLARK_35C.data.u8[0] = 0xC0; // enable charging and discharging if (datalayer.battery.status.bms_status == FAULT) - PYLON_35C.data.u8[0] = 0x00; // disable all + SOLARK_35C.data.u8[0] = 0x00; // disable all else if (datalayer.battery.settings.user_set_voltage_limits_active && datalayer.battery.status.voltage_dV > datalayer.battery.settings.max_user_set_charge_voltage_dV) - PYLON_35C.data.u8[0] = 0x40; // only allow discharging + SOLARK_35C.data.u8[0] = 0x40; // only allow discharging else if (datalayer.battery.settings.user_set_voltage_limits_active && datalayer.battery.status.voltage_dV < datalayer.battery.settings.max_user_set_discharge_voltage_dV) - PYLON_35C.data.u8[0] = 0xA0; // enable charing, set charge immediately + SOLARK_35C.data.u8[0] = 0xA0; // enable charing, set charge immediately else if (datalayer.battery.status.real_soc <= datalayer.battery.settings.min_percentage) - PYLON_35C.data.u8[0] = 0xA0; // enable charing, set charge immediately + SOLARK_35C.data.u8[0] = 0xA0; // enable charing, set charge immediately else if (datalayer.battery.status.real_soc >= datalayer.battery.settings.max_percentage) - PYLON_35C.data.u8[0] = 0x40; // enable discharging only + SOLARK_35C.data.u8[0] = 0x40; // enable discharging only - // PYLON_35E is pre-filled with the manufacturer name + // SOLARK_35E is pre-filled with the manufacturer name (BAT-EMU) } void SolArkLvInverter::map_can_frame_to_variable(CAN_frame rx_frame) { @@ -115,11 +102,11 @@ void SolArkLvInverter::transmit_can(unsigned long currentMillis) { if (currentMillis - previousMillis1000ms >= INTERVAL_1_S) { previousMillis1000ms = currentMillis; - transmit_can_frame(&PYLON_351); - transmit_can_frame(&PYLON_355); - transmit_can_frame(&PYLON_356); - transmit_can_frame(&PYLON_359); - transmit_can_frame(&PYLON_35C); - transmit_can_frame(&PYLON_35E); + transmit_can_frame(&SOLARK_351); + transmit_can_frame(&SOLARK_355); + transmit_can_frame(&SOLARK_356); + transmit_can_frame(&SOLARK_359); + transmit_can_frame(&SOLARK_35C); + transmit_can_frame(&SOLARK_35E); } }