[fronius] Add battery control Thing actions (#17170)

* [fronius] Add DTOs for /config/timeofuse HTTP endpoint

Signed-off-by: Florian Hotze <florianh_dev@icloud.com>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Florian Hotze 2024-07-30 09:01:39 +02:00 committed by Ciprian Pascu
parent c370ba2ba3
commit a9ce328497
15 changed files with 1024 additions and 21 deletions

View File

@ -2,23 +2,24 @@
This binding uses the [Fronius Solar API V1](https://www.fronius.com/en/solar-energy/installers-partners/technical-data/all-products/system-monitoring/open-interfaces/fronius-solar-api-json-) to obtain data from Fronius devices.
It supports Fronius inverters and Fronius Smart Meter.
Supports:
It supports Fronius inverters, smart meters and Ohmpilot devices connected to a Fronius Datamanager 1.0 / 2.0, Fronius Datalogger or with integrated Solar API V1 support.
Inverters with integrated Solar API V1 support include:
- Fronius Galvo
- Fronius Primo
- Fronius Symo
- Fronius Symo Gen24
- Fronius Smart Meter 63A
- Fronius Smart Meter TS 65A-3
- Fronius Ohmpilot
- Fronius Symo Gen24 Plus
## Supported Things
| Thing Type | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `bridge` | The Bridge |
| `powerinverter` | Fronius Galvo, Symo and other Fronius inverters in combination with the Fronius Datamanager 1.0 / 2.0 or Fronius Datalogger. You can add multiple inverters that depend on the same datalogger with different device ids. (Default 1) |
| `meter` | Fronius Smart Meter. You can add multiple smart meters with different device ids. (The default id = 0) |
| `ohmpilot` | Fronius Ohmpilot. (The default id = 0) |
| `powerinverter` | Fronius Galvo, Symo and other Fronius inverters: You can add multiple inverters that depend on the same datalogger with different device ids. (default id = 1) |
| `meter` | Fronius Smart Meter: You can add multiple smart meters with different device ids. (default id = 0) |
| `ohmpilot` | Fronius Ohmpilot ( default id = 0) |
## Discovery
@ -32,10 +33,12 @@ The binding has no configuration options, all configuration is done at `bridge`,
### Bridge Thing Configuration
| Parameter | Description |
| ----------------- | ----------------------------------------------------- |
| `hostname` | The hostname or IP address of your Fronius Datalogger |
| `refreshInterval` | Refresh interval in seconds |
| Parameter | Description | Required |
|-------------------|--------------------------------------------------------------------------------|----------|
| `hostname` | The hostname or IP address of your Fronius Datamanager, Datalogger or inverter | Yes |
| `username` | The username to authenticate with the inverter settings for battery control | No |
| `password` | The password to authenticate with the inverter settings for battery control | No |
| `refreshInterval` | Refresh interval in seconds | No |
### Powerinverter Thing Configuration
@ -138,6 +141,65 @@ The binding has no configuration options, all configuration is done at `bridge`,
| `modelId` | The model name of the ohmpilot |
| `serialNumber` | The serial number of the ohmpilot |
## Actions
:::tip Warning
Battery control uses the battery management's time-dependent battery control settings of the inverter settings and therefore overrides user-specified time of use settings.
Please note that user-specified time of use plans cannot be used together with battery control, as battery control will override the user-specified time of use settings.
:::
The `powerinverter` Thing provides actions to control the battery charging and discharging behaviour of hybrid inverters, such as Symo Gen24 Plus, if username and password are provided in the bridge configuration.
You can retrieve the actions as follows:
:::: tabs
::: tab DSL
```java
val froniusInverterActions = getActions("fronius", "fronius:powerinverter:mybridge:myinverter")
```
:::
::: tab JS
```javascript
var froniusInverterActions = actions.thingActions('fronius', 'fronius:powerinverter:mybridge:myinverter');
```
:::
::::
Where the first parameter must always be `fronius` and the second must be the full Thing UID of the inverter.
### Available Actions
Once the actions instance has been retrieved, you can invoke the following methods:
- `resetBatteryControl()`: Remove all battery control schedules from the inverter.
- `holdBatteryCharge()`: Prevent the battery from discharging (removes all battery control schedules first and applies all the time).
- `addHoldBatteryChargeSchedule(LocalTime from, LocalTime until)`: Add a schedule to prevent the battery from discharging in the specified time range.
- `addHoldBatteryChargeSchedule(ZonedDateTime from, ZonedDateTime until)`: Add a schedule to prevent the battery from discharging in the specified time range.
- `forceBatteryCharging(QuantityType<Power> power)`: Force the battery to charge with the specified power (removes all battery control schedules first and applies all the time).
- `addForcedBatteryChargingSchedule(LocalTime from, LocalTime until, QuantityType<Power> power)`: Add a schedule to force the battery to charge with the specified power in the specified time range.
- `addForcedBatteryChargingSchedule(ZonedDateTime from, ZonedDateTime until, QuantityType<Power> power)`: Add a schedule to force the battery to charge with the specified power in the specified time range.
### Examples
```javascript
var froniusInverterActions = actions.thingActions('fronius', 'fronius:powerinverter:mybridge:myinverter');
froniusInverterActions.resetBatteryControl();
froniusInverterActions.holdBatteryCharge();
froniusInverterActions.forceBatteryCharging(Quantity('5 kW'));
froniusInverterActions.resetBatteryControl();
froniusInverterActions.addHoldBatteryChargeSchedule(time.toZDT('18:00'), time.toZDT('22:00'));
froniusInverterActions.addForcedBatteryChargingSchedule(time.toZDT('22:00'), time.toZDT('23:59'), Quantity('5 kW'));
froniusInverterActions.addForcedBatteryChargingSchedule(time.toZDT('00:00'), time.toZDT('06:00'), Quantity('5 kW'));
```
## Full Example
demo.things:

View File

@ -20,5 +20,7 @@ package org.openhab.binding.fronius.internal;
*/
public class FroniusBridgeConfiguration {
public String hostname;
public String username;
public String password;
public Integer refreshInterval;
}

View File

@ -23,13 +23,16 @@ import org.openhab.binding.fronius.internal.handler.FroniusBridgeHandler;
import org.openhab.binding.fronius.internal.handler.FroniusMeterHandler;
import org.openhab.binding.fronius.internal.handler.FroniusOhmpilotHandler;
import org.openhab.binding.fronius.internal.handler.FroniusSymoInverterHandler;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link FroniusHandlerFactory} is responsible for creating things and thing
@ -54,6 +57,13 @@ public class FroniusHandlerFactory extends BaseThingHandlerFactory {
}
};
private final HttpClientFactory httpClientFactory;
@Activate
public FroniusHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
this.httpClientFactory = httpClientFactory;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
@ -64,7 +74,7 @@ public class FroniusHandlerFactory extends BaseThingHandlerFactory {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(THING_TYPE_INVERTER)) {
return new FroniusSymoInverterHandler(thing);
return new FroniusSymoInverterHandler(thing, httpClientFactory.getCommonHttpClient());
} else if (thingTypeUID.equals(THING_TYPE_BRIDGE)) {
return new FroniusBridgeHandler((Bridge) thing);
} else if (thingTypeUID.equals(THING_TYPE_METER)) {

View File

@ -0,0 +1,162 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fronius.internal.action;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import javax.measure.quantity.Power;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fronius.internal.handler.FroniusSymoInverterHandler;
import org.openhab.core.automation.annotation.ActionInput;
import org.openhab.core.automation.annotation.RuleAction;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.thing.binding.ThingActions;
import org.openhab.core.thing.binding.ThingActionsScope;
import org.openhab.core.thing.binding.ThingHandler;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ServiceScope;
/**
* Implementation of the {@link ThingActions} interface used for controlling battery charging and discharging for
* Fronius hybrid inverters.
*
* @author Florian Hotze - Initial contribution
*/
@Component(scope = ServiceScope.PROTOTYPE, service = FroniusSymoInverterActions.class)
@ThingActionsScope(name = "fronius")
@NonNullByDefault
public class FroniusSymoInverterActions implements ThingActions {
private @Nullable FroniusSymoInverterHandler handler;
public static void resetBatteryControl(ThingActions actions) {
if (actions instanceof FroniusSymoInverterActions froniusSymoInverterActions) {
froniusSymoInverterActions.resetBatteryControl();
} else {
throw new IllegalArgumentException(
"The 'actions' argument is not an instance of FroniusSymoInverterActions");
}
}
public static void holdBatteryCharge(ThingActions actions) {
if (actions instanceof FroniusSymoInverterActions froniusSymoInverterActions) {
froniusSymoInverterActions.holdBatteryCharge();
} else {
throw new IllegalArgumentException(
"The 'actions' argument is not an instance of FroniusSymoInverterActions");
}
}
public static void addHoldBatteryChargeSchedule(ThingActions actions, LocalTime from, LocalTime until) {
if (actions instanceof FroniusSymoInverterActions froniusSymoInverterActions) {
froniusSymoInverterActions.addHoldBatteryChargeSchedule(from, until);
} else {
throw new IllegalArgumentException(
"The 'actions' argument is not an instance of FroniusSymoInverterActions");
}
}
public static void addHoldBatteryChargeSchedule(ThingActions actions, ZonedDateTime from, ZonedDateTime until) {
addHoldBatteryChargeSchedule(actions, from.toLocalTime(), until.toLocalTime());
}
public static void forceBatteryCharging(ThingActions actions, QuantityType<Power> power) {
if (actions instanceof FroniusSymoInverterActions froniusSymoInverterActions) {
froniusSymoInverterActions.forceBatteryCharging(power);
} else {
throw new IllegalArgumentException(
"The 'actions' argument is not an instance of FroniusSymoInverterActions");
}
}
public static void addForcedBatteryChargingSchedule(ThingActions actions, LocalTime from, LocalTime until,
QuantityType<Power> power) {
if (actions instanceof FroniusSymoInverterActions froniusSymoInverterActions) {
froniusSymoInverterActions.addForcedBatteryChargingSchedule(from, until, power);
} else {
throw new IllegalArgumentException(
"The 'actions' argument is not an instance of FroniusSymoInverterActions");
}
}
public static void addForcedBatteryChargingSchedule(ThingActions actions, ZonedDateTime from, ZonedDateTime until,
QuantityType<Power> power) {
addForcedBatteryChargingSchedule(actions, from.toLocalTime(), until.toLocalTime(), power);
}
@Override
public void setThingHandler(@Nullable ThingHandler handler) {
this.handler = (FroniusSymoInverterHandler) handler;
}
@Override
public @Nullable ThingHandler getThingHandler() {
return handler;
}
@RuleAction(label = "@text/actions.reset-battery-control.label", description = "@text/actions.reset-battery-control.description")
public void resetBatteryControl() {
FroniusSymoInverterHandler handler = this.handler;
if (handler != null) {
handler.resetBatteryControl();
}
}
@RuleAction(label = "@text/actions.hold-battery-charge.label", description = "@text/actions.hold-battery-charge.description")
public void holdBatteryCharge() {
FroniusSymoInverterHandler handler = this.handler;
if (handler != null) {
handler.holdBatteryCharge();
}
}
@RuleAction(label = "@text/actions.add-hold-battery-charge-schedule.label", description = "@text/actions.add-hold-battery-charge-schedule.description")
public void addHoldBatteryChargeSchedule(
@ActionInput(name = "from", label = "@text/actions.from.label", description = "@text/actions.from.description") LocalTime from,
@ActionInput(name = "until", label = "@text/actions.until.label", description = "@text/actions.until.description") LocalTime until) {
FroniusSymoInverterHandler handler = this.handler;
if (handler != null) {
handler.addHoldBatteryChargeSchedule(from, until);
}
}
public void addHoldBatteryChargeSchedule(ZonedDateTime from, ZonedDateTime until) {
addHoldBatteryChargeSchedule(from.toLocalTime(), until.toLocalTime());
}
@RuleAction(label = "@text/actions.force-battery-charging.label", description = "@text/actions.force-battery-charging.description")
public void forceBatteryCharging(
@ActionInput(name = "power", label = "@text/actions.power.label", description = "@text/actions.power.label") QuantityType<Power> power) {
FroniusSymoInverterHandler handler = this.handler;
if (handler != null) {
handler.forceBatteryCharging(power);
}
}
@RuleAction(label = "@text/actions.add-forced-battery-charging-schedule.label", description = "@text/actions.add-forced-battery-charging-schedule.description")
public void addForcedBatteryChargingSchedule(
@ActionInput(name = "from", label = "@text/actions.from.label", description = "@text/actions.from.description") LocalTime from,
@ActionInput(name = "until", label = "@text/actions.until.label", description = "@text/actions.until.description") LocalTime until,
@ActionInput(name = "power", label = "@text/actions.power.label", description = "@text/actions.power.label") QuantityType<Power> power) {
FroniusSymoInverterHandler handler = this.handler;
if (handler != null) {
handler.addForcedBatteryChargingSchedule(from, until, power);
}
}
public void addForcedBatteryChargingSchedule(ZonedDateTime from, ZonedDateTime until, QuantityType<Power> power) {
addForcedBatteryChargingSchedule(from.toLocalTime(), until.toLocalTime(), power);
}
}

View File

@ -0,0 +1,195 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fronius.internal.api;
import static org.openhab.binding.fronius.internal.FroniusBindingConstants.API_TIMEOUT;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Properties;
import javax.measure.quantity.Power;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol.ScheduleType;
import org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol.TimeOfUseRecord;
import org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol.TimeOfUseRecords;
import org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol.TimeTableRecord;
import org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol.WeekdaysRecord;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
/**
* The {@link FroniusBatteryControl} is responsible for controlling the battery of Fronius hybrid inverters through the
* battery management's time-dependent battery control settings.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class FroniusBatteryControl {
private static final String TIME_OF_USE_ENDPOINT = "/config/timeofuse";
private static final Logger LOGGER = LoggerFactory.getLogger(FroniusBatteryControl.class);
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private static final WeekdaysRecord ALL_WEEKDAYS_RECORD = new WeekdaysRecord(true, true, true, true, true, true,
true);
private static final LocalTime BEGIN_OF_DAY = LocalTime.of(0, 0);
private static final LocalTime END_OF_DAY = LocalTime.of(23, 59);
private final Gson gson = new Gson();
private final HttpClient httpClient;
private final URI baseUri;
private final String username;
private final String password;
private final URI timeOfUseUri;
public FroniusBatteryControl(HttpClient httpClient, URI baseUri, String username, String password) {
this.httpClient = httpClient;
this.baseUri = baseUri;
this.username = username;
this.password = password;
this.timeOfUseUri = baseUri.resolve(URI.create(TIME_OF_USE_ENDPOINT));
}
/**
* Gets the time of use settings of the Fronius hybrid inverter.
*
* @return the time of use settings
* @throws FroniusCommunicationException if an error occurs during communication with the inverter
*/
private TimeOfUseRecords getTimeOfUse() throws FroniusCommunicationException {
// Login and get the auth header for the next request
String authHeader = FroniusConfigAuthUtil.login(httpClient, baseUri, username, password, HttpMethod.GET,
timeOfUseUri.getPath(), API_TIMEOUT);
Properties headers = new Properties();
headers.put(HttpHeader.AUTHORIZATION.asString(), authHeader);
// Get the time of use settings
String response = FroniusHttpUtil.executeUrl(HttpMethod.GET, timeOfUseUri.toString(), headers, null, null,
API_TIMEOUT);
LOGGER.trace("Time of Use settings read successfully");
// Parse the response body
TimeOfUseRecords records;
try {
records = gson.fromJson(response, TimeOfUseRecords.class);
} catch (JsonSyntaxException jse) {
throw new FroniusCommunicationException("Failed to parse Time of Use settings", jse);
}
if (records == null) {
throw new FroniusCommunicationException("Failed to parse Time of Use settings");
}
return records;
}
/**
* Sets the time of use settings of the Fronius hybrid inverter.
*
* @param records the time of use settings
* @throws FroniusCommunicationException if an error occurs during communication with the inverter
*/
private void setTimeOfUse(TimeOfUseRecords records) throws FroniusCommunicationException {
// Login and get the auth header for the next request
String authHeader = FroniusConfigAuthUtil.login(httpClient, baseUri, username, password, HttpMethod.POST,
timeOfUseUri.getPath(), API_TIMEOUT);
Properties headers = new Properties();
headers.put(HttpHeader.AUTHORIZATION.asString(), authHeader);
// Set the time of use settings
String json = gson.toJson(records);
FroniusHttpUtil.executeUrl(HttpMethod.POST, timeOfUseUri.toString(), headers,
new ByteArrayInputStream(json.getBytes()), "application/json", API_TIMEOUT);
LOGGER.trace("Time of Use settings set successfully");
}
/**
* Resets the time of use settings (i.e. removes all time-dependent battery control settings) of the Fronius hybrid
* inverter.
*
* @throws FroniusCommunicationException when an error occurs during communication with the inverter
*/
public void reset() throws FroniusCommunicationException {
setTimeOfUse(new TimeOfUseRecords(new TimeOfUseRecord[0]));
}
/**
* Holds the battery charge right now, i.e. prevents the battery from discharging.
*
* @throws FroniusCommunicationException when an error occurs during communication with the inverter
*/
public void holdBatteryCharge() throws FroniusCommunicationException {
reset();
addHoldBatteryChargeSchedule(BEGIN_OF_DAY, END_OF_DAY);
}
/**
* Holds the battery charge during a specific time period, i.e. prevents the battery from discharging in that
* period.
*
* @param from start time of the hold charge period
* @param until end time of the hold charge period
* @throws FroniusCommunicationException when an error occurs during communication with the inverter
*/
public void addHoldBatteryChargeSchedule(LocalTime from, LocalTime until) throws FroniusCommunicationException {
TimeOfUseRecord[] currentTimeOfUse = getTimeOfUse().records();
TimeOfUseRecord[] timeOfUse = new TimeOfUseRecord[currentTimeOfUse.length + 1];
System.arraycopy(currentTimeOfUse, 0, timeOfUse, 0, currentTimeOfUse.length);
TimeOfUseRecord holdCharge = new TimeOfUseRecord(true, 0, ScheduleType.DISCHARGE_MAX,
new TimeTableRecord(from.format(TIME_FORMATTER), until.format(TIME_FORMATTER)), ALL_WEEKDAYS_RECORD);
timeOfUse[timeOfUse.length - 1] = holdCharge;
setTimeOfUse(new TimeOfUseRecords(timeOfUse));
}
/**
* Forces the battery to charge right now with the specified power.
*
* @param power the power to charge the battery with
* @throws FroniusCommunicationException when an error occurs during communication with the inverter
*/
public void forceBatteryCharging(QuantityType<Power> power) throws FroniusCommunicationException {
reset();
addForcedBatteryChargingSchedule(BEGIN_OF_DAY, END_OF_DAY, power);
}
/**
* Forces the battery to charge during a specific time period with the specified power.
*
* @param from start time of the forced charge period
* @param until end time of the forced charge period
* @param power the power to charge the battery with
* @throws FroniusCommunicationException when an error occurs during communication with the inverter
*/
public void addForcedBatteryChargingSchedule(LocalTime from, LocalTime until, QuantityType<Power> power)
throws FroniusCommunicationException {
TimeOfUseRecords currentTimeOfUse = getTimeOfUse();
TimeOfUseRecord[] timeOfUse = new TimeOfUseRecord[currentTimeOfUse.records().length + 1];
System.arraycopy(currentTimeOfUse.records(), 0, timeOfUse, 0, currentTimeOfUse.records().length);
TimeOfUseRecord holdCharge = new TimeOfUseRecord(true, power.toUnit(Units.WATT).intValue(),
ScheduleType.CHARGE_MIN, new TimeTableRecord(from.format(TIME_FORMATTER), until.format(TIME_FORMATTER)),
ALL_WEEKDAYS_RECORD);
timeOfUse[timeOfUse.length - 1] = holdCharge;
setTimeOfUse(new TimeOfUseRecords(timeOfUse));
}
}

View File

@ -0,0 +1,291 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fronius.internal.api;
import java.io.IOException;
import java.net.URI;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link FroniusConfigAuthUtil} handles the authentication process to access Fronius inverter settings, which are
* available on the <code>/config</code> HTTP endpoints.
* <br>
* Due to Fronius not using the standard HTTP authorization header, it is not possible to use
* {@link org.eclipse.jetty.client.api.AuthenticationStore} together with
* {@link org.eclipse.jetty.client.util.DigestAuthentication} to authenticate against the Fronius inverter settings.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class FroniusConfigAuthUtil {
private static final String AUTHENTICATE_HEADER = "X-Www-Authenticate";
private static final String DIGEST_AUTH_HEADER_FORMAT = "Digest username=\"%s\", realm=\"%s\", nonce=\"%s\", uri=\"%s\", response=\"%s\", qop=%s, nc=%08x, cnonce=\"%s\"";
private static final String LOGIN_ENDPOINT = "/commands/Login";
private static final Logger LOGGER = LoggerFactory.getLogger(FroniusConfigAuthUtil.class);
/**
* Sends a HTTP GET request to the given login URI and extracts the authentication parameters from the
* authentication header.
* This method uses a {@link Response.Listener.Adapter} to intercept the response headers and extract the
* authentication header, as normal digest authentication using
* {@link org.eclipse.jetty.client.util.DigestAuthentication} does not work because Fronius uses a custom
* authentication header.
*
* @param httpClient the {@link HttpClient} to use for the request
* @param loginUri the {@link URI} of the login endpoint
* @return a {@link Map} containing the authentication parameters of the authentication challenge
* @throws IOException when the response does not contain the expected authentication header
*/
private static Map<String, String> getAuthParams(HttpClient httpClient, URI loginUri, int timeout)
throws IOException {
LOGGER.debug("Sending login request to get authentication challenge");
CountDownLatch latch = new CountDownLatch(1);
Request initialRequest = httpClient.newRequest(loginUri).timeout(timeout, TimeUnit.MILLISECONDS);
XWwwAuthenticateHeaderListener XWwwAuthenticateHeaderListener = new XWwwAuthenticateHeaderListener(latch);
initialRequest.onResponseHeaders(XWwwAuthenticateHeaderListener);
initialRequest.send(result -> latch.countDown());
// Wait for the request to complete
try {
latch.await();
} catch (InterruptedException ie) {
throw new RuntimeException(ie);
}
String authHeader = XWwwAuthenticateHeaderListener.getAuthHeader();
if (authHeader == null) {
throw new IOException("No authentication header found in login response");
}
LOGGER.debug("Parsing authentication challenge");
// Extract parameters from the header
Map<String, String> params = new HashMap<>();
String[] parts = authHeader.split(" ", 2)[1].split(",");
for (String part : parts) {
part = part.trim();
String[] keyValue = part.split("=");
if (keyValue.length == 2) {
String key = keyValue[0].trim();
String value = keyValue[1].replace("\"", "").trim();
params.put(key, value);
}
}
return params;
}
/**
* Creates a Digest Authentication header for the given parameters.
*
* @param nonce
* @param realm
* @param qop
* @param uri
* @param method
* @param username
* @param password
* @param nc
* @param cnonce
* @return the digest authentication header
* @throws FroniusCommunicationException if an authentication parameter is missing
*/
private static String createDigestHeader(@Nullable String nonce, @Nullable String realm, @Nullable String qop,
String uri, HttpMethod method, String username, String password, int nc, String cnonce)
throws FroniusCommunicationException {
if (nonce == null || realm == null || qop == null) {
throw new FroniusCommunicationException("Missing authentication parameter");
}
LOGGER.debug("Creating digest authentication header");
String ha1 = md5Hex(username + ":" + realm + ":" + password);
String ha2 = md5Hex(method.asString() + ":" + uri);
String response = md5Hex(
ha1 + ":" + nonce + ":" + String.format("%08x", nc) + ":" + cnonce + ":" + qop + ":" + ha2);
return String.format(DIGEST_AUTH_HEADER_FORMAT, username, realm, nonce, uri, response, qop, nc, cnonce);
}
/**
* Computes the MD5 has of the given data and returns it as a hex string.
*
* @param data the data to hash
* @return the hashed data as a hex string
*/
private static String md5Hex(String data) {
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
// should never occur
throw new RuntimeException(e);
}
byte[] array = md.digest(data.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : array) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
/**
* Performs the login request to the Fronius inverter's settings.
*
* @param httpClient the {@link HttpClient} to use for the request
* @param loginUri the {@link URI} of the login endpoint
* @param authHeader the authentication header to use for the login request
* @throws InterruptedException when the request is interrupted
* @throws FroniusCommunicationException when the login request failed
*/
private static void performLoginRequest(HttpClient httpClient, URI loginUri, String authHeader, int timeout)
throws InterruptedException, FroniusCommunicationException {
Request loginRequest = httpClient.newRequest(loginUri).header(HttpHeader.AUTHORIZATION, authHeader)
.timeout(timeout, TimeUnit.MILLISECONDS);
ContentResponse loginResponse;
try {
loginResponse = loginRequest.send();
if (loginResponse.getStatus() != 200) {
throw new FroniusCommunicationException(
"Failed to send login request, status code: " + loginResponse.getStatus());
}
} catch (TimeoutException | ExecutionException e) {
throw new FroniusCommunicationException("Failed to send login request", e);
}
}
/**
* Logs in to the Fronius inverter settings, retries on failure and returns the authentication header for the next
* request.
*
* @param httpClient the {@link HttpClient} to use for the request
* @param baseUri the base URI of the Fronius inverter
* @param username the username to use for the login
* @param password the password to use for the login
* @param method the {@link HttpMethod} to be used by the next request
* @param relativeUrl the relative URL to be accessed with the next request
* @param timeout the timeout in milliseconds for the login requests
* @return the authentication header for the next request
* @throws FroniusCommunicationException when the login failed or interrupted
*/
public static synchronized String login(HttpClient httpClient, URI baseUri, String username, String password,
HttpMethod method, String relativeUrl, int timeout) throws FroniusCommunicationException {
// Perform request to get authentication parameters
LOGGER.debug("Getting authentication parameters");
URI loginUri = baseUri.resolve(URI.create(LOGIN_ENDPOINT + "?user=" + username));
String relativeLoginUrl = LOGIN_ENDPOINT + "?user=" + username;
Map<String, String> authDetails;
int attemptCount = 1;
try {
while (true) {
Throwable lastException;
try {
authDetails = getAuthParams(httpClient, loginUri, timeout);
break;
} catch (IOException e) {
LOGGER.debug("HTTP error on attempt #{} {}", attemptCount, loginUri);
Thread.sleep(500 * attemptCount);
attemptCount++;
lastException = e;
}
if (attemptCount >= 3) {
LOGGER.debug("Failed connecting to {} after {} attempts.", loginUri, attemptCount, lastException);
throw new FroniusCommunicationException("Unable to connect", lastException);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new FroniusCommunicationException("Interrupted", e);
}
// Create auth header for login request
int nc = 1;
String cnonce = md5Hex(String.valueOf(System.currentTimeMillis()));
String authHeader = createDigestHeader(authDetails.get("nonce"), authDetails.get("realm"),
authDetails.get("qop"), relativeLoginUrl, HttpMethod.GET, username, password, nc, cnonce);
// Perform login request with Digest Authentication
LOGGER.debug("Sending login request");
attemptCount = 1;
try {
while (true) {
Throwable lastException;
try {
performLoginRequest(httpClient, loginUri, authHeader, timeout);
break;
} catch (InterruptedException ie) {
throw new FroniusCommunicationException("Failed to send login request", ie);
} catch (FroniusCommunicationException e) {
LOGGER.debug("HTTP error on attempt #{} {}", attemptCount, loginUri);
Thread.sleep(500 * attemptCount);
attemptCount++;
lastException = e;
}
if (attemptCount >= 3) {
LOGGER.debug("Failed connecting to {} after {} attempts.", loginUri, attemptCount, lastException);
throw new FroniusCommunicationException("Unable to connect", lastException);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new FroniusCommunicationException("Interrupted", e);
}
// Create new auth header for next request
LOGGER.debug("Login successful, creating auth header for next request");
nc++;
authHeader = createDigestHeader(authDetails.get("nonce"), authDetails.get("realm"), authDetails.get("qop"),
relativeUrl, method, username, password, nc, cnonce);
return authHeader;
}
/**
* Listener to extract the X-Www-Authenticate header from the response of a {@link Request}.
*/
private static class XWwwAuthenticateHeaderListener extends Response.Listener.Adapter {
private final CountDownLatch latch;
private @Nullable String authHeader;
public XWwwAuthenticateHeaderListener(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void onHeaders(Response response) {
authHeader = response.getHeaders().get(AUTHENTICATE_HEADER);
latch.countDown();
}
public @Nullable String getAuthHeader() {
return authHeader;
}
}
}

View File

@ -13,19 +13,20 @@
package org.openhab.binding.fronius.internal.api;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.core.io.net.http.HttpUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* A version of HttpUtil implementation that retries on failure.
*
* @author Jimmy Tanagra - Initial contribution
*
*/
@NonNullByDefault
public class FroniusHttpUtil {
@ -42,13 +43,33 @@ public class FroniusHttpUtil {
*/
public static synchronized String executeUrl(HttpMethod httpMethod, String url, int timeout)
throws FroniusCommunicationException {
return executeUrl(httpMethod, url, null, null, null, timeout);
}
/**
* Issue a HTTP request and retry on failure.
*
* @param httpMethod the {@link HttpMethod} to use
* @param url the url to execute
* @param httpHeaders optional http request headers which has to be sent within request
* @param content the content to be sent to the given <code>url</code> or <code>null</code> if no content should be
* sent.
* @param contentType the content type of the given <code>content</code>
* @param timeout the socket timeout in milliseconds to wait for data
* @return the response body
* @throws FroniusCommunicationException when the request execution failed or interrupted
*/
public static synchronized String executeUrl(HttpMethod httpMethod, String url, @Nullable Properties httpHeaders,
@Nullable InputStream content, @Nullable String contentType, int timeout)
throws FroniusCommunicationException {
int attemptCount = 1;
try {
while (true) {
Throwable lastException = null;
String result = null;
try {
result = HttpUtil.executeUrl(httpMethod.asString(), url, timeout);
result = HttpUtil.executeUrl(httpMethod.asString(), url, httpHeaders, content, contentType,
timeout);
} catch (IOException e) {
// HttpUtil::executeUrl wraps InterruptedException into IOException.
// Unwrap and rethrow it so that we don't retry on InterruptedException

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol;
/**
* Enum for the schedule type of the battery control.
*
* @author Florian Hotze - Initial contribution
*/
public enum ScheduleType {
CHARGE_MIN,
CHARGE_MAX,
DISCHARGE_MIN,
DISCHARGE_MAX
}

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.SerializedName;
/**
* Record representing an entry of {@link TimeOfUseRecords}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public record TimeOfUseRecord(@SerializedName("Active") boolean active, @SerializedName("Power") int power,
@SerializedName("ScheduleType") ScheduleType scheduleType,
@SerializedName("TimeTable") TimeTableRecord timeTable, @SerializedName("Weekdays") WeekdaysRecord weekdays) {
}

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.SerializedName;
/**
* Record representing the data received from or sent to the <code>/config/timeofuse</code> HTTP endpoint of Fronius
* hybrid inverters.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public record TimeOfUseRecords(@SerializedName("timeofuse") TimeOfUseRecord[] records) {
}

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.SerializedName;
/**
* Record representing the "TimeTable" node of a {@link TimeOfUseRecord}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public record TimeTableRecord(@SerializedName("Start") String start, @SerializedName("End") String end) {
}

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fronius.internal.api.dto.inverter.batterycontrol;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.SerializedName;
/**
* Record representing the "Weekdays" node of {@link TimeOfUseRecord}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public record WeekdaysRecord(@SerializedName("Mon") boolean monday, @SerializedName("Tue") boolean tuesday,
@SerializedName("Wed") boolean wednesday, @SerializedName("Thu") boolean thursday,
@SerializedName("Fri") boolean friday, @SerializedName("Sat") boolean saturday,
@SerializedName("Sun") boolean sunday) {
}

View File

@ -12,14 +12,22 @@
*/
package org.openhab.binding.fronius.internal.handler;
import java.net.URI;
import java.time.LocalTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import javax.measure.Unit;
import javax.measure.quantity.Power;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.fronius.internal.FroniusBaseDeviceConfiguration;
import org.openhab.binding.fronius.internal.FroniusBindingConstants;
import org.openhab.binding.fronius.internal.FroniusBridgeConfiguration;
import org.openhab.binding.fronius.internal.action.FroniusSymoInverterActions;
import org.openhab.binding.fronius.internal.api.FroniusBatteryControl;
import org.openhab.binding.fronius.internal.api.FroniusCommunicationException;
import org.openhab.binding.fronius.internal.api.dto.ValueUnit;
import org.openhab.binding.fronius.internal.api.dto.inverter.InverterDeviceStatus;
@ -35,7 +43,10 @@ import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link FroniusSymoInverterHandler} is responsible for updating the data, which are
@ -45,15 +56,21 @@ import org.openhab.core.types.State;
* @author Peter Schraffl - Added device status and error status channels
* @author Thomas Kordelle - Added inverter power, battery state of charge and PV solar yield
* @author Jimmy Tanagra - Add powerflow autonomy, self consumption channels
* @author Florian Hotze - Add battery control actions
*/
public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(FroniusSymoInverterHandler.class);
private final HttpClient httpClient;
private @Nullable InverterRealtimeResponse inverterRealtimeResponse;
private @Nullable PowerFlowRealtimeResponse powerFlowResponse;
private FroniusBaseDeviceConfiguration config;
private @Nullable FroniusBatteryControl batteryControl;
public FroniusSymoInverterHandler(Thing thing) {
public FroniusSymoInverterHandler(Thing thing, HttpClient httpClient) {
super(thing);
this.httpClient = httpClient;
}
@Override
@ -70,9 +87,81 @@ public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
@Override
public void initialize() {
config = getConfigAs(FroniusBaseDeviceConfiguration.class);
FroniusBridgeConfiguration bridgeConfig = getBridge().getConfiguration().as(FroniusBridgeConfiguration.class);
if (bridgeConfig.username != null && bridgeConfig.password != null) {
batteryControl = new FroniusBatteryControl(httpClient, URI.create("http://" + bridgeConfig.hostname + "/"),
bridgeConfig.username, bridgeConfig.password);
}
super.initialize();
}
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return List.of(FroniusSymoInverterActions.class);
}
private @Nullable FroniusBatteryControl getBatteryControl() {
if (batteryControl == null) {
logger.warn("Battery control is not available. Check the bridge configuration.");
}
return batteryControl;
}
public void resetBatteryControl() {
FroniusBatteryControl batteryControl = getBatteryControl();
if (batteryControl != null) {
try {
batteryControl.reset();
} catch (FroniusCommunicationException e) {
logger.warn("Failed to reset battery control", e);
}
}
}
public void holdBatteryCharge() {
FroniusBatteryControl batteryControl = getBatteryControl();
if (batteryControl != null) {
try {
batteryControl.holdBatteryCharge();
} catch (FroniusCommunicationException e) {
logger.warn("Failed to set battery control to hold battery charge", e);
}
}
}
public void addHoldBatteryChargeSchedule(LocalTime from, LocalTime until) {
FroniusBatteryControl batteryControl = getBatteryControl();
if (batteryControl != null) {
try {
batteryControl.addHoldBatteryChargeSchedule(from, until);
} catch (FroniusCommunicationException e) {
logger.warn("Failed to add hold battery charge schedule to battery control", e);
}
}
}
public void forceBatteryCharging(QuantityType<Power> power) {
FroniusBatteryControl batteryControl = getBatteryControl();
if (batteryControl != null) {
try {
batteryControl.forceBatteryCharging(power);
} catch (FroniusCommunicationException e) {
logger.warn("Failed to set battery control to force battery charge", e);
}
}
}
public void addForcedBatteryChargingSchedule(LocalTime from, LocalTime until, QuantityType<Power> power) {
FroniusBatteryControl batteryControl = getBatteryControl();
if (batteryControl != null) {
try {
batteryControl.addForcedBatteryChargingSchedule(from, until, power);
} catch (FroniusCommunicationException e) {
logger.warn("Failed to add forced battery charge schedule to battery control", e);
}
}
}
/**
* Update the channel from the last data retrieved
*
@ -92,6 +181,7 @@ public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
return null;
}
InverterDeviceStatus deviceStatus;
switch (fieldName) {
case FroniusBindingConstants.INVERTER_DATA_CHANNEL_PAC:
return getQuantityOrZero(inverterData.getPac(), Units.WATT);
@ -129,7 +219,7 @@ public class FroniusSymoInverterHandler extends FroniusBaseThingHandler {
// Convert the unit to MWh for backwards compatibility with non-quantity type
return getQuantityOrZero(inverterData.getYearEnergy(), Units.MEGAWATT_HOUR).toUnit("MWh");
case FroniusBindingConstants.INVERTER_DATA_CHANNEL_DEVICE_STATUS_ERROR_CODE:
InverterDeviceStatus deviceStatus = inverterData.getDeviceStatus();
deviceStatus = inverterData.getDeviceStatus();
if (deviceStatus == null) {
return null;
}

View File

@ -33,8 +33,12 @@ thing-type.fronius.powerinverter.description = Fronius Symo power inverter
thing-type.config.fronius.bridge.hostname.label = Hostname
thing-type.config.fronius.bridge.hostname.description = The hostname or IP address of the Fronius gateway/device
thing-type.config.fronius.bridge.password.label = Password
thing-type.config.fronius.bridge.password.description = The password to access the configuration of the Fronius gateway/device, required only for battery control
thing-type.config.fronius.bridge.refreshInterval.label = Refresh Interval
thing-type.config.fronius.bridge.refreshInterval.description = Specifies the refresh interval in seconds.
thing-type.config.fronius.bridge.username.label = Username
thing-type.config.fronius.bridge.username.description = The username to access the configuration of the Fronius gateway/device, required only for battery control
thing-type.config.fronius.meter.deviceId.label = Device ID
thing-type.config.fronius.meter.deviceId.description = Specific device identifier
thing-type.config.fronius.ohmpilot.deviceId.label = Device ID
@ -111,3 +115,22 @@ channel-type.fronius.udc3.label = DC Voltage 3
channel-type.fronius.udc3.description = DC voltage of MPPT tracker 3
channel-type.fronius.year_energy.label = Year Energy
channel-type.fronius.year_energy.description = Energy generated in current year
# actions
actions.reset-battery-control.label = Reset Battery Control
actions.reset-battery-control.description = Remove all battery control schedules from the inverter
actions.hold-battery-charge.label = Hold Battery Charge
actions.hold-battery-charge.description = Prevent the battery from discharging
actions.add-hold-battery-charge-schedule.label = Add Hold Battery Charge Schedule
actions.add-hold-battery-charge-schedule.description = Add a schedule to prevent the battery from discharging in the specified time range
actions.force-battery-charging.label = Force Battery Charging
actions.force-battery-charging.description = Force the battery to charge with the specified power
actions.add-forced-battery-charging-schedule.label = Add Forced Battery Charging Schedule
actions.add-forced-battery-charging-schedule.description = Add a schedule to force the battery to charge with the specified power in the specified time range
actions.from.label = Begin Timestamp
actions.from.description = The beginning of the time range
actions.until.label = End Timestamp
actions.until.description = The (inclusive) end of the time range
actions.power.label = Power
actions.power.description = The power to charge the battery with

View File

@ -13,6 +13,18 @@
<label>Hostname</label>
<description>The hostname or IP address of the Fronius gateway/device</description>
</parameter>
<parameter name="username" type="text" required="false">
<label>Username</label>
<description>The username to access the configuration of the Fronius gateway/device, required only for battery
control</description>
<default>customer</default>
</parameter>
<parameter name="password" type="text" required="false">
<context>password</context>
<label>Password</label>
<description>The password to access the configuration of the Fronius gateway/device, required only for battery
control</description>
</parameter>
<parameter name="refreshInterval" type="integer" min="1">
<label>Refresh Interval</label>
<description>Specifies the refresh interval in seconds.</description>