]> git.basschouten.com Git - openhab-addons.git/commitdiff
[fronius] Add battery control Thing actions (#17170)
authorFlorian Hotze <florianh_dev@icloud.com>
Tue, 30 Jul 2024 07:01:39 +0000 (09:01 +0200)
committerGitHub <noreply@github.com>
Tue, 30 Jul 2024 07:01:39 +0000 (09:01 +0200)
* [fronius] Add DTOs for /config/timeofuse HTTP endpoint

Signed-off-by: Florian Hotze <florianh_dev@icloud.com>
15 files changed:
bundles/org.openhab.binding.fronius/README.md
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/FroniusBridgeConfiguration.java
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/FroniusHandlerFactory.java
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/action/FroniusSymoInverterActions.java [new file with mode: 0644]
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusBatteryControl.java [new file with mode: 0644]
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusConfigAuthUtil.java [new file with mode: 0644]
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusHttpUtil.java
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/ScheduleType.java [new file with mode: 0644]
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeOfUseRecord.java [new file with mode: 0644]
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeOfUseRecords.java [new file with mode: 0644]
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeTableRecord.java [new file with mode: 0644]
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/WeekdaysRecord.java [new file with mode: 0644]
bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/handler/FroniusSymoInverterHandler.java
bundles/org.openhab.binding.fronius/src/main/resources/OH-INF/i18n/fronius.properties
bundles/org.openhab.binding.fronius/src/main/resources/OH-INF/thing/bridge.xml

index 678657c37bb9ce85946909b457a0c504ba8ba038..b7e314db991f94f4e54f7e9e88bd86b8cc36f006 100644 (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)                                                                                                                                                                                                |
+| Thing Type      | Description                                                                                                                                                    |
+|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `bridge`        | The Bridge                                                                                                                                                     |
+| `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:
index 139dc912d5d5dbe806883a050addbb6eba6ff99c..a056406f9c035059abaa8804f6e546c4cf3f6939 100644 (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;
 }
index 5c621d6043949b6990f3287abb52c94d7ac8e459..4eb2304d9fc06c5d6152684fb7d833a36dd9df5e 100644 (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)) {
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/action/FroniusSymoInverterActions.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/action/FroniusSymoInverterActions.java
new file mode 100644 (file)
index 0000000..fb2e67e
--- /dev/null
@@ -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);
+    }
+}
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusBatteryControl.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusBatteryControl.java
new file mode 100644 (file)
index 0000000..3a56320
--- /dev/null
@@ -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));
+    }
+}
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusConfigAuthUtil.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/FroniusConfigAuthUtil.java
new file mode 100644 (file)
index 0000000..b7e151d
--- /dev/null
@@ -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;
+        }
+    }
+}
index ccc26119a4b9d862ce3c41252345f4ab468e293a..d68245b19cc48046e4d7d16f0c2225a13d2544df 100644 (file)
 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
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/ScheduleType.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/ScheduleType.java
new file mode 100644 (file)
index 0000000..9644ec2
--- /dev/null
@@ -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
+}
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeOfUseRecord.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeOfUseRecord.java
new file mode 100644 (file)
index 0000000..26b537a
--- /dev/null
@@ -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) {
+}
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeOfUseRecords.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeOfUseRecords.java
new file mode 100644 (file)
index 0000000..e88086c
--- /dev/null
@@ -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) {
+}
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeTableRecord.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/TimeTableRecord.java
new file mode 100644 (file)
index 0000000..6045ef3
--- /dev/null
@@ -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) {
+}
diff --git a/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/WeekdaysRecord.java b/bundles/org.openhab.binding.fronius/src/main/java/org/openhab/binding/fronius/internal/api/dto/inverter/batterycontrol/WeekdaysRecord.java
new file mode 100644 (file)
index 0000000..8a5edee
--- /dev/null
@@ -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) {
+}
index 803009756c93145fc13370a9c5ec4f7cfafc976f..1c80ded6e1563e6b40badeddf7bf49fd31a64077 100644 (file)
  */
 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;
                 }
index a35ced3a738139046c5a744963eb619fbb5410d3..a5c1d08c80839462978c4afc8e6104e47a27daa8 100644 (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
index 462889377f57a575642b8032fa31675894467e2f..143dd95d4ba390828da768bb57e07e588bbdaa96 100644 (file)
                                <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>