Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,32 @@
@AttributeDefinition(name = "Maximum apparent power", description = "Maximum inverter apparent power in [VA].")
int maxApparentPower() default 12_000;

@AttributeDefinition(name = "Enable settings writes", description = "Safety gate for FC16 settings writes. Disabled by default.")
boolean controlEnabled() default false;

@AttributeDefinition(name = "Discharge cutoff SoC", description = "Target for E00F in percent; -1 leaves unchanged.")
int dischargeCutoffSoc() default -1;

@AttributeDefinition(name = "Stop-charge current", description = "Target for E01C in amperes; -1 leaves unchanged.")
int stopChargeCurrent() default -1;

@AttributeDefinition(name = "Stop-charge SoC", description = "Target for E01D in percent; -1 leaves unchanged.")
int stopChargeSoc() default -1;

@AttributeDefinition(name = "Low-SoC alarm", description = "Target for E01E in percent; -1 leaves unchanged.")
int lowSocAlarm() default -1;

@AttributeDefinition(name = "Switch to line SoC", description = "Target for E01F in percent; -1 leaves unchanged.")
int switchToLineSoc() default -1;

@AttributeDefinition(name = "Switch to battery SoC", description = "Target for E020 in percent; -1 leaves unchanged.")
int switchToBatterySoc() default -1;

@AttributeDefinition(name = "AC charge-current limit", description = "Target for E205 in amperes; -1 leaves unchanged.")
int acChargeCurrentLimit() default -1;

@AttributeDefinition(name = "Maximum charge-current limit", description = "Target for E20A in amperes; -1 leaves unchanged.")
int maxChargeCurrentLimit() default -1;

String webconsole_configurationFactory_nameHint() default "SRNE Battery-Inverter [{id}]";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package io.openems.edge.ess.srne.batteryinverter;

import io.openems.common.types.OptionsEnum;
import io.openems.edge.bridge.modbus.api.task.Task.ExecuteState;

/**
* Coordinates one idempotent settings write and its read-back verification.
* A failed or mismatched write is never retried automatically.
*
* <p>Thread-safety: {@code queueIfChanged} is called from the Edge cycle thread
* (via {@code run()}), while {@code onExecute} and {@code verify} are called from
* the Modbus bridge worker thread (task execute callback and read-back channel
* callback). The mutable {@code state}/{@code target} are therefore guarded by
* intrinsic locking so transitions are atomic and visible across those threads.
*/
final class SafeWriteHandler {

public enum State implements OptionsEnum {
UNDEFINED(-1), IDLE(0), QUEUED(1), AWAITING_READBACK(2), VERIFIED(3), FAILED(4);

private final int value;

private State(int value) {
this.value = value;
}

@Override
public int getValue() {
return this.value;
}

@Override
public String getName() {
return this.name();
}

@Override
public OptionsEnum getUndefined() {
return UNDEFINED;
}
}

private State state = State.IDLE;
private Integer target;
private int awaitingReadbackCycles;

public synchronized boolean queueIfChanged(Integer actual, int target) {
if (this.state != State.IDLE || actual == null || actual == target) {
return false;
}
this.target = target;
this.state = State.QUEUED;
return true;
}

public synchronized void onExecute(ExecuteState executeState) {
if (this.state != State.QUEUED || executeState == ExecuteState.NO_OP) {
return;
}
this.awaitingReadbackCycles = 0;
this.state = executeState == ExecuteState.OK ? State.AWAITING_READBACK : State.FAILED;
}

/**
* Advances the bounded readback wait without ever retrying the write.
*
* @param timeoutCycles number of Edge cycles allowed for a fresh readback
*/
public synchronized void onCycle(int timeoutCycles) {
if (this.state != State.AWAITING_READBACK) {
return;
}
if (++this.awaitingReadbackCycles >= timeoutCycles) {
this.state = State.FAILED;
}
}

/**
* Rejects an invalid setting before any Modbus write is queued.
*
* @return true only for the first rejection, for one-shot audit logging
*/
public synchronized boolean reject() {
if (this.state != State.IDLE) {
return false;
}
this.state = State.FAILED;
return true;
}

public synchronized void verify(Integer actual) {
if (this.state != State.AWAITING_READBACK || actual == null) {
return;
}
this.state = actual.equals(this.target) ? State.VERIFIED : State.FAILED;
}

public synchronized State getState() {
return this.state;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ public enum ChannelId implements io.openems.edge.common.channel.ChannelId {
MACHINE_STATE(Doc.of(MachineState.values()) //
.text("Machine state from holding register 0x0210")), //
STATE_MACHINE(Doc.of(State.values()) //
.text("Grid/off-grid lifecycle derived from the machine state")); //
.text("Grid/off-grid lifecycle derived from the machine state")), //
DISCHARGE_CUTOFF_SOC(Doc.of(OpenemsType.INTEGER).unit(Unit.PERCENT)), //
STOP_CHARGE_CURRENT(Doc.of(OpenemsType.INTEGER).unit(Unit.MILLIAMPERE)), //
STOP_CHARGE_SOC(Doc.of(OpenemsType.INTEGER).unit(Unit.PERCENT)), //
LOW_SOC_ALARM(Doc.of(OpenemsType.INTEGER).unit(Unit.PERCENT)), //
SWITCH_TO_LINE_SOC(Doc.of(OpenemsType.INTEGER).unit(Unit.PERCENT)), //
SWITCH_TO_BATTERY_SOC(Doc.of(OpenemsType.INTEGER).unit(Unit.PERCENT)), //
AC_CHARGE_CURRENT_LIMIT(Doc.of(OpenemsType.INTEGER).unit(Unit.MILLIAMPERE)), //
MAX_CHARGE_CURRENT_LIMIT(Doc.of(OpenemsType.INTEGER).unit(Unit.MILLIAMPERE)), //
SAFE_WRITE_STATE(Doc.of(SafeWriteHandler.State.values()) //
.text("Aggregate state of the guarded settings write operation")); //

private final Doc doc;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.metatype.annotations.Designate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.openems.common.exceptions.OpenemsError.OpenemsNamedException;
import io.openems.common.exceptions.OpenemsException;
Expand All @@ -30,6 +32,8 @@
import io.openems.edge.bridge.modbus.api.element.SignedWordElement;
import io.openems.edge.bridge.modbus.api.element.UnsignedWordElement;
import io.openems.edge.bridge.modbus.api.task.FC3ReadRegistersTask;
import io.openems.edge.bridge.modbus.api.task.FC16WriteRegistersTask;
import io.openems.edge.common.channel.IntegerReadChannel;
import io.openems.edge.common.channel.value.Value;
import io.openems.edge.common.component.OpenemsComponent;
import io.openems.edge.common.startstop.StartStop;
Expand All @@ -50,9 +54,23 @@
public class SrneBatteryInverterImpl extends AbstractOpenemsModbusComponent
implements SrneBatteryInverter, Srne, OffGridBatteryInverter, ManagedSymmetricBatteryInverter,
SymmetricBatteryInverter, ModbusComponent, OpenemsComponent, StartStoppable {
private static final int READBACK_TIMEOUT_CYCLES = 30;
private final Logger log = LoggerFactory.getLogger(SrneBatteryInverterImpl.class);

private final AtomicReference<TargetGridMode> targetGridMode = new AtomicReference<>(TargetGridMode.GO_ON_GRID);
private final AtomicReference<StartStop> startStopTarget = new AtomicReference<>(StartStop.UNDEFINED);
private final UnsignedWordElement dischargeCutoffSocWrite = new UnsignedWordElement(0xE00F);
private final UnsignedWordElement stopChargeCurrentWrite = new UnsignedWordElement(0xE01C);
private final UnsignedWordElement stopChargeSocWrite = new UnsignedWordElement(0xE01D);
private final UnsignedWordElement lowSocAlarmWrite = new UnsignedWordElement(0xE01E);
private final UnsignedWordElement switchToLineSocWrite = new UnsignedWordElement(0xE01F);
private final UnsignedWordElement switchToBatterySocWrite = new UnsignedWordElement(0xE020);
private final UnsignedWordElement acChargeCurrentLimitWrite = new UnsignedWordElement(0xE205);
private final UnsignedWordElement maxChargeCurrentLimitWrite = new UnsignedWordElement(0xE20A);
private final SafeWriteHandler[] writeHandlers = { new SafeWriteHandler(), new SafeWriteHandler(),
new SafeWriteHandler(), new SafeWriteHandler(), new SafeWriteHandler(), new SafeWriteHandler(),
new SafeWriteHandler(), new SafeWriteHandler() };
private Config config;

@Override
@Reference(//
Expand All @@ -77,6 +95,13 @@ public SrneBatteryInverterImpl() {

@Activate
private void activate(ComponentContext context, Config config) throws OpenemsException {
this.config = config;
/*
* There is intentionally no @Modified method. A settings configuration update
* causes DS to replace this component instance, giving every setting a fresh
* SafeWriteHandler. VERIFIED and FAILED therefore remain terminal for one
* configured request and cannot trigger repeated writes in the same activation.
*/
super.activate(context, config.id(), config.alias(), config.enabled(), config.modbusUnitId());
this._setMaxApparentPower(config.maxApparentPower());
this.getMachineStateChannel().onSetNextValue(ignore -> this.updateLifecycle());
Expand All @@ -97,6 +122,22 @@ private void activate(ComponentContext context, Config config) throws OpenemsExc
};
this.getBatteryVoltageChannel().onSetNextValue(updateActivePower);
this.getBatteryCurrentChannel().onSetNextValue(updateActivePower);

this.registerReadback(0, SrneBatteryInverter.ChannelId.DISCHARGE_CUTOFF_SOC);
this.registerReadback(1, SrneBatteryInverter.ChannelId.STOP_CHARGE_CURRENT);
this.registerReadback(2, SrneBatteryInverter.ChannelId.STOP_CHARGE_SOC);
this.registerReadback(3, SrneBatteryInverter.ChannelId.LOW_SOC_ALARM);
this.registerReadback(4, SrneBatteryInverter.ChannelId.SWITCH_TO_LINE_SOC);
this.registerReadback(5, SrneBatteryInverter.ChannelId.SWITCH_TO_BATTERY_SOC);
this.registerReadback(6, SrneBatteryInverter.ChannelId.AC_CHARGE_CURRENT_LIMIT);
this.registerReadback(7, SrneBatteryInverter.ChannelId.MAX_CHARGE_CURRENT_LIMIT);
}

private void registerReadback(int index, SrneBatteryInverter.ChannelId channelId) {
((IntegerReadChannel) this.channel(channelId)).onSetNextValue(value -> {
this.writeHandlers[index].verify(value.get());
this.channel(SrneBatteryInverter.ChannelId.SAFE_WRITE_STATE).setNextValue(this.aggregateWriteState());
});
}

@Override
Expand Down Expand Up @@ -129,13 +170,40 @@ private void updateLifecycle() {
@Override
protected ModbusProtocol defineModbusProtocol() {
return new ModbusProtocol(this, //
new FC3ReadRegistersTask(0xE00F, Priority.LOW, //
m(SrneBatteryInverter.ChannelId.DISCHARGE_CUTOFF_SOC,
new UnsignedWordElement(0xE00F))), //
new FC3ReadRegistersTask(0xE01C, Priority.LOW, //
m(SrneBatteryInverter.ChannelId.STOP_CHARGE_CURRENT, new UnsignedWordElement(0xE01C),
SCALE_FACTOR_2), //
m(SrneBatteryInverter.ChannelId.STOP_CHARGE_SOC, new UnsignedWordElement(0xE01D)), //
m(SrneBatteryInverter.ChannelId.LOW_SOC_ALARM, new UnsignedWordElement(0xE01E)), //
m(SrneBatteryInverter.ChannelId.SWITCH_TO_LINE_SOC, new UnsignedWordElement(0xE01F)), //
m(SrneBatteryInverter.ChannelId.SWITCH_TO_BATTERY_SOC,
new UnsignedWordElement(0xE020))), //
new FC3ReadRegistersTask(0xE205, Priority.LOW, //
m(SrneBatteryInverter.ChannelId.AC_CHARGE_CURRENT_LIMIT,
new UnsignedWordElement(0xE205), SCALE_FACTOR_2)), //
new FC3ReadRegistersTask(0xE20A, Priority.LOW, //
m(SrneBatteryInverter.ChannelId.MAX_CHARGE_CURRENT_LIMIT,
new UnsignedWordElement(0xE20A), SCALE_FACTOR_2)), //
new FC3ReadRegistersTask(0x0101, Priority.HIGH, //
m(SrneBatteryInverter.ChannelId.BATTERY_VOLTAGE, new UnsignedWordElement(0x0101),
SCALE_FACTOR_2), //
m(SrneBatteryInverter.ChannelId.BATTERY_CURRENT, new SignedWordElement(0x0102),
SCALE_FACTOR_2)), //
new FC3ReadRegistersTask(0x0210, Priority.HIGH, //
m(SrneBatteryInverter.ChannelId.MACHINE_STATE, new UnsignedWordElement(0x0210))));
m(SrneBatteryInverter.ChannelId.MACHINE_STATE, new UnsignedWordElement(0x0210))), //
new FC16WriteRegistersTask(this.writeHandlers[0]::onExecute, 0xE00F, this.dischargeCutoffSocWrite), //
new FC16WriteRegistersTask(this.writeHandlers[1]::onExecute, 0xE01C, this.stopChargeCurrentWrite), //
new FC16WriteRegistersTask(this.writeHandlers[2]::onExecute, 0xE01D, this.stopChargeSocWrite), //
new FC16WriteRegistersTask(this.writeHandlers[3]::onExecute, 0xE01E, this.lowSocAlarmWrite), //
new FC16WriteRegistersTask(this.writeHandlers[4]::onExecute, 0xE01F, this.switchToLineSocWrite), //
new FC16WriteRegistersTask(this.writeHandlers[5]::onExecute, 0xE020, this.switchToBatterySocWrite), //
new FC16WriteRegistersTask(this.writeHandlers[6]::onExecute, 0xE205,
this.acChargeCurrentLimitWrite), //
new FC16WriteRegistersTask(this.writeHandlers[7]::onExecute, 0xE20A,
this.maxChargeCurrentLimitWrite));
}

@Override
Expand All @@ -150,11 +218,91 @@ public void setStartStop(StartStop value) throws OpenemsNamedException {

@Override
public void run(Battery battery, int setActivePower, int setReactivePower) throws OpenemsNamedException {
/*
* Story 18 is intentionally read-only. Story 19 will consume targetGridMode and
* apply the power set-points through the validated FC16 control path.
*/
this.updateLifecycle();
this.reconcileSafeSettings();
}

/**
* Continuous max charge/discharge current of the SR-SE10B (205Ah LFP) is 100A;
* 120A is only the 3-second peak (datasheet SRNE_SE series V1.5). Current-limit
* settings are capped at the continuous rating so a configured limit can never
* exceed what the battery allows.
*/
private static final int MAX_BATTERY_CURRENT_A = 100;

private void reconcileSafeSettings() {
for (var handler : this.writeHandlers) {
handler.onCycle(READBACK_TIMEOUT_CYCLES);
}
this.channel(SrneBatteryInverter.ChannelId.SAFE_WRITE_STATE).setNextValue(this.aggregateWriteState());
if (this.config == null || !this.config.controlEnabled()) {
return;
}
MachineState machineState = this.getMachineStateChannel().value().asEnum();
if (machineState == null || !machineState.isVerified()) {
return;
}

this.reconcile(0, SrneBatteryInverter.ChannelId.DISCHARGE_CUTOFF_SOC,
this.config.dischargeCutoffSoc(), 0, 100, 1, this.dischargeCutoffSocWrite);
this.reconcile(1, SrneBatteryInverter.ChannelId.STOP_CHARGE_CURRENT,
this.config.stopChargeCurrent(), 0, MAX_BATTERY_CURRENT_A, 10, this.stopChargeCurrentWrite);
this.reconcile(2, SrneBatteryInverter.ChannelId.STOP_CHARGE_SOC,
this.config.stopChargeSoc(), 0, 100, 1, this.stopChargeSocWrite);
this.reconcile(3, SrneBatteryInverter.ChannelId.LOW_SOC_ALARM,
this.config.lowSocAlarm(), 0, 100, 1, this.lowSocAlarmWrite);
this.reconcile(4, SrneBatteryInverter.ChannelId.SWITCH_TO_LINE_SOC,
this.config.switchToLineSoc(), 0, 100, 1, this.switchToLineSocWrite);
this.reconcile(5, SrneBatteryInverter.ChannelId.SWITCH_TO_BATTERY_SOC,
this.config.switchToBatterySoc(), 0, 100, 1, this.switchToBatterySocWrite);
this.reconcile(6, SrneBatteryInverter.ChannelId.AC_CHARGE_CURRENT_LIMIT,
this.config.acChargeCurrentLimit(), 0, MAX_BATTERY_CURRENT_A, 10, this.acChargeCurrentLimitWrite);
this.reconcile(7, SrneBatteryInverter.ChannelId.MAX_CHARGE_CURRENT_LIMIT,
this.config.maxChargeCurrentLimit(), 0, MAX_BATTERY_CURRENT_A, 10, this.maxChargeCurrentLimitWrite);
this.channel(SrneBatteryInverter.ChannelId.SAFE_WRITE_STATE).setNextValue(this.aggregateWriteState());
}

private void reconcile(int index, SrneBatteryInverter.ChannelId channelId, int configuredTarget, int min, int max,
int rawMultiplier, UnsignedWordElement writeElement) {
if (configuredTarget < 0) {
return;
}
var handler = this.writeHandlers[index];
Integer actual = ((IntegerReadChannel) this.channel(channelId)).value().get();
if (configuredTarget < min || configuredTarget > max) {
if (handler.reject()) {
this.logWarn(this.log, "Rejected out-of-range setting [" + channelId.name() + "] value ["
+ configuredTarget + "]; validated range is [" + min + ".." + max + "]");
}
return;
}
int target = configuredTarget;
int channelTarget = target * (rawMultiplier == 10 ? 1_000 : 1);
if (handler.queueIfChanged(actual, channelTarget)) {
writeElement.setNextWriteValue(target * rawMultiplier);
}
}

private SafeWriteHandler.State aggregateWriteState() {
var result = SafeWriteHandler.State.IDLE;
for (var handler : this.writeHandlers) {
var state = handler.getState();
if (writeStatePrecedence(state) > writeStatePrecedence(result)) {
result = state;
}
}
return result;
}

static int writeStatePrecedence(SafeWriteHandler.State state) {
return switch (state) {
case FAILED -> 5;
case AWAITING_READBACK -> 4;
case QUEUED -> 3;
case VERIFIED -> 2;
case IDLE -> 1;
case UNDEFINED -> 0;
};
}

@Override
Expand Down
Loading