]> git.basschouten.com Git - openhab-addons.git/commitdiff
[daikin] Add demand control support for ac_unit (#17087)
authorjimtng <2554958+jimtng@users.noreply.github.com>
Fri, 26 Jul 2024 09:41:55 +0000 (19:41 +1000)
committerGitHub <noreply@github.com>
Fri, 26 Jul 2024 09:41:55 +0000 (11:41 +0200)
Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
bundles/org.openhab.binding.daikin/README.md
bundles/org.openhab.binding.daikin/src/main/java/org/openhab/binding/daikin/internal/DaikinBindingConstants.java
bundles/org.openhab.binding.daikin/src/main/java/org/openhab/binding/daikin/internal/DaikinWebTargets.java
bundles/org.openhab.binding.daikin/src/main/java/org/openhab/binding/daikin/internal/api/DemandControl.java [new file with mode: 0644]
bundles/org.openhab.binding.daikin/src/main/java/org/openhab/binding/daikin/internal/api/Enums.java
bundles/org.openhab.binding.daikin/src/main/java/org/openhab/binding/daikin/internal/handler/DaikinAcUnitHandler.java
bundles/org.openhab.binding.daikin/src/main/resources/OH-INF/i18n/daikin.properties
bundles/org.openhab.binding.daikin/src/main/resources/OH-INF/thing/thing-types.xml
bundles/org.openhab.binding.daikin/src/main/resources/OH-INF/update/update.xml [new file with mode: 0644]
bundles/org.openhab.binding.daikin/src/test/java/org/openhab/binding/daikin/internal/api/ControlInfoTest.java
bundles/org.openhab.binding.daikin/src/test/java/org/openhab/binding/daikin/internal/api/DemandControlTest.java [new file with mode: 0644]

index 1145fb476768d1784efb538fe4cfef3fe33f5a18..95b2d6089323a285b871f1404939613ab40b94ab 100644 (file)
@@ -45,50 +45,53 @@ A BRP072C42 adapter requires a registered UUID to authenticate. Upon discovery,
 The temperature channels have a precision of one half degree Celsius.
 For the BRP072A42 and BRP072C42:
 
-| Channel Name                | Description                                                                                                                     |
-| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
-| power                       | Turns the power on/off for the air conditioning unit.                                                                           |
-| settemp                     | The temperature set for the air conditioning unit.                                                                              |
-| indoortemp                  | The indoor temperature as measured by the unit.                                                                                 |
-| outdoortemp                 | The outdoor temperature as measured by the external part of the air conditioning system. May not be available when unit is off. |
-| humidity                    | The indoor humidity as measured by the unit. This is not available on all units.                                                |
-| mode                        | The mode set for the unit (AUTO, DEHUMIDIFIER, COLD, HEAT, FAN)                                                                 |
-| homekitmode                 | A mode that is compatible with homekit/alexa/google home (off, auto, heat, cool). Not tested for BRP069B41                      |
-| fanspeed                    | The fan speed set for the unit (AUTO, SILENCE, LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4, LEVEL_5)                                     |
-| fandir                      | The fan blade direction (STOPPED, VERTICAL, HORIZONTAL, VERTICAL_AND_HORIZONTAL)                                                |
-| cmpfrequency                | The compressor frequency                                                                                                        |
-| specialmode                 | The special mode set for the unit (NORMAL, ECO, POWERFUL). This is not available on all units.                                  |
-| streamer                    | Turns the streamer feature on/off for the air conditioning unit. This is not available on all units.                            |
-| energyheatingtoday          | The energy consumption when heating for today                                                                                   |
-| energyheatingthisweek       | The energy consumption when heating for this week                                                                               |
-| energyheatinglastweek       | The energy consumption when heating for last week                                                                               |
-| energyheatingcurrentyear-1  | The energy consumption when heating for current year January                                                                    |
-| energyheatingcurrentyear-2  | The energy consumption when heating for current year February                                                                   |
-| energyheatingcurrentyear-3  | The energy consumption when heating for current year March                                                                      |
-| energyheatingcurrentyear-4  | The energy consumption when heating for current year April                                                                      |
-| energyheatingcurrentyear-5  | The energy consumption when heating for current year May                                                                        |
-| energyheatingcurrentyear-6  | The energy consumption when heating for current year June                                                                       |
-| energyheatingcurrentyear-7  | The energy consumption when heating for current year July                                                                       |
-| energyheatingcurrentyear-8  | The energy consumption when heating for current year August                                                                     |
-| energyheatingcurrentyear-9  | The energy consumption when heating for current year September                                                                  |
-| energyheatingcurrentyear-10 | The energy consumption when heating for current year October                                                                    |
-| energyheatingcurrentyear-11 | The energy consumption when heating for current year November                                                                   |
-| energyheatingcurrentyear-12 | The energy consumption when heating for current year December                                                                   |
-| energycoolingtoday          | The energy consumption when cooling for today                                                                                   |
-| energycoolingthisweek       | The energy consumption when cooling for this week                                                                               |
-| energycoolinglastweek       | The energy consumption when cooling for last week                                                                               |
-| energycoolingcurrentyear-1  | The energy consumption when cooling for current year January                                                                    |
-| energycoolingcurrentyear-2  | The energy consumption when cooling for current year February                                                                   |
-| energycoolingcurrentyear-3  | The energy consumption when cooling for current year March                                                                      |
-| energycoolingcurrentyear-4  | The energy consumption when cooling for current year April                                                                      |
-| energycoolingcurrentyear-5  | The energy consumption when cooling for current year May                                                                        |
-| energycoolingcurrentyear-6  | The energy consumption when cooling for current year June                                                                       |
-| energycoolingcurrentyear-7  | The energy consumption when cooling for current year July                                                                       |
-| energycoolingcurrentyear-8  | The energy consumption when cooling for current year August                                                                     |
-| energycoolingcurrentyear-9  | The energy consumption when cooling for current year September                                                                  |
-| energycoolingcurrentyear-10 | The energy consumption when cooling for current year October                                                                    |
-| energycoolingcurrentyear-11 | The energy consumption when cooling for current year November                                                                   |
-| energycoolingcurrentyear-12 | The energy consumption when cooling for current year December                                                                   |
+| Channel Name                | Description                                                                                                                                                                                                                                    |
+| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| power                       | Turns the power on/off for the air conditioning unit.                                                                                                                                                                                          |
+| settemp                     | The temperature set for the air conditioning unit.                                                                                                                                                                                             |
+| indoortemp                  | The indoor temperature as measured by the unit.                                                                                                                                                                                                |
+| outdoortemp                 | The outdoor temperature as measured by the external part of the air conditioning system. May not be available when unit is off.                                                                                                                |
+| humidity                    | The indoor humidity as measured by the unit. This is not available on all units.                                                                                                                                                               |
+| mode                        | The mode set for the unit (AUTO, DEHUMIDIFIER, COLD, HEAT, FAN)                                                                                                                                                                                |
+| homekitmode                 | A mode that is compatible with homekit/alexa/google home (off, auto, heat, cool). Not tested for BRP069B41                                                                                                                                     |
+| fanspeed                    | The fan speed set for the unit (AUTO, SILENCE, LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4, LEVEL_5)                                                                                                                                                    |
+| fandir                      | The fan blade direction (STOPPED, VERTICAL, HORIZONTAL, VERTICAL_AND_HORIZONTAL)                                                                                                                                                               |
+| cmpfrequency                | The compressor frequency                                                                                                                                                                                                                       |
+| specialmode                 | The special mode set for the unit (NORMAL, ECO, POWERFUL). This is not available on all units.                                                                                                                                                 |
+| streamer                    | Turns the streamer feature on/off for the air conditioning unit. This is not available on all units.                                                                                                                                           |
+| energyheatingtoday          | The energy consumption when heating for today                                                                                                                                                                                                  |
+| energyheatingthisweek       | The energy consumption when heating for this week                                                                                                                                                                                              |
+| energyheatinglastweek       | The energy consumption when heating for last week                                                                                                                                                                                              |
+| energyheatingcurrentyear-1  | The energy consumption when heating for current year January                                                                                                                                                                                   |
+| energyheatingcurrentyear-2  | The energy consumption when heating for current year February                                                                                                                                                                                  |
+| energyheatingcurrentyear-3  | The energy consumption when heating for current year March                                                                                                                                                                                     |
+| energyheatingcurrentyear-4  | The energy consumption when heating for current year April                                                                                                                                                                                     |
+| energyheatingcurrentyear-5  | The energy consumption when heating for current year May                                                                                                                                                                                       |
+| energyheatingcurrentyear-6  | The energy consumption when heating for current year June                                                                                                                                                                                      |
+| energyheatingcurrentyear-7  | The energy consumption when heating for current year July                                                                                                                                                                                      |
+| energyheatingcurrentyear-8  | The energy consumption when heating for current year August                                                                                                                                                                                    |
+| energyheatingcurrentyear-9  | The energy consumption when heating for current year September                                                                                                                                                                                 |
+| energyheatingcurrentyear-10 | The energy consumption when heating for current year October                                                                                                                                                                                   |
+| energyheatingcurrentyear-11 | The energy consumption when heating for current year November                                                                                                                                                                                  |
+| energyheatingcurrentyear-12 | The energy consumption when heating for current year December                                                                                                                                                                                  |
+| energycoolingtoday          | The energy consumption when cooling for today                                                                                                                                                                                                  |
+| energycoolingthisweek       | The energy consumption when cooling for this week                                                                                                                                                                                              |
+| energycoolinglastweek       | The energy consumption when cooling for last week                                                                                                                                                                                              |
+| energycoolingcurrentyear-1  | The energy consumption when cooling for current year January                                                                                                                                                                                   |
+| energycoolingcurrentyear-2  | The energy consumption when cooling for current year February                                                                                                                                                                                  |
+| energycoolingcurrentyear-3  | The energy consumption when cooling for current year March                                                                                                                                                                                     |
+| energycoolingcurrentyear-4  | The energy consumption when cooling for current year April                                                                                                                                                                                     |
+| energycoolingcurrentyear-5  | The energy consumption when cooling for current year May                                                                                                                                                                                       |
+| energycoolingcurrentyear-6  | The energy consumption when cooling for current year June                                                                                                                                                                                      |
+| energycoolingcurrentyear-7  | The energy consumption when cooling for current year July                                                                                                                                                                                      |
+| energycoolingcurrentyear-8  | The energy consumption when cooling for current year August                                                                                                                                                                                    |
+| energycoolingcurrentyear-9  | The energy consumption when cooling for current year September                                                                                                                                                                                 |
+| energycoolingcurrentyear-10 | The energy consumption when cooling for current year October                                                                                                                                                                                   |
+| energycoolingcurrentyear-11 | The energy consumption when cooling for current year November                                                                                                                                                                                  |
+| energycoolingcurrentyear-12 | The energy consumption when cooling for current year December                                                                                                                                                                                  |
+| demandcontrolmode           | The demand control mode (`OFF`, `AUTO`, `MANUAL`, `SCHEDULED`)                                                                                                                                                                                 |
+| demandcontrolmaxpower       | The maximum power when in `MANUAL` mode. Values between 40 and 100 are accepted in an increment of 5. In `SCHEDULED` demand control mode, this channel will be updated with the calculated maximum power based on the current active schedule. |
+| demandcontrolschedule       | A JSON string that contains the scheduled demand control settings. See below.                                                                                                                                                                  |
 
 For the BRP15B61:
 
@@ -110,6 +113,135 @@ For the BRP15B61:
 | zone7           | Turns zone 7 on/off for the air conditioning unit.                                                                                                                |
 | zone8           | Turns zone 8 on/off for the air conditioning unit.                                                                                                                |
 
+## Demand Control
+
+Some units have a _demand control_ feature to limit the maximum power usage to a certain percentage.
+This is set through the `demandcontrolmode` channel which accepts `OFF`, `MANUAL`, `SCHEDULED`, or `AUTO`.
+
+When changing the mode from `MANUAL` to another mode, the maximum power setting will be saved in the Binding's memory and restored when switching the mode back to `MANUAL`.
+Equally, when changing the mode from `SCHEDULED` to another mode, the current schedule will be saved in the Binding's memory and restored when switching the mode back to `SCHEDULED`.
+
+### Manual Demand Control
+
+Manual demand control requires setting the `demandcontrolmaxpower` channel to the desired limit.
+The unit accepts values between 40% and 100% in increments of 5.
+
+Sending a command to the `demandcontrolmaxpower` channel will automatically switch the demand control mode to `MANUAL`.
+
+### Scheduled Demand Control
+
+It is possible to set the demand control power limit based on day of the week and time of day schedules.
+When the unit is in scheduled demand control mode, the binding provides the current schedule through the `demandcontrolschedule` channel.
+
+In `SCHEDULED`, the `demandcontrolmaxpower` channel will provide the _current_ maximum power in effect, as defined within the schedule.
+This information is not provided by the unit itself.
+It is calculated by the binding based on the current schedule.
+Therefore, it is important to ensure that openHAB's local time is in sync with the unit's date/time.
+
+The schedule and associated max power settings can be set by sending a command to the `demandcontrolschedule` channel.
+When doing so, the demand control mode will automatically change to `SCHEDULED`, if it wasn't already in that mode.
+
+The schedule is specified in a JSON string in the following format:
+
+```json
+{
+  "monday": [
+    {
+      "enabled": true,
+      "time": <minutes from midnight>,
+      "power": <power in percent>
+    }
+  ],
+  "tuesday": [
+    // Schedule entries for Tuesday
+  ],
+  "wednesday": [
+
+  ],
+  // more days up to Sunday
+  "sunday": [
+
+  ]
+}
+```
+
+Concrete example:
+
+The JSON format doesn't actually support comments. They are provided for clarity.
+
+```json
+{
+  "monday": [
+    {
+      "enabled": true,
+      "time": 480, // 8 am
+      "power": 80
+    },
+    {
+      "enabled": true,
+      "time": 600, // 10 am
+      "power": 100
+    },
+    {
+      "enabled": true,
+      "time": 960, // 4pm
+      "power": 50
+    }
+  ],
+  "tuesday": [
+    {
+      "enabled": true,
+      "time": 480, // 8 am
+      "power": 80
+    },
+    {
+      "enabled": true,
+      "time": 600, // 10 am
+      "power": 100
+    },
+    {
+      "enabled": true,
+      "time": 960, // 4pm
+      "power": 50
+    }
+  ],
+  "wednesday": [
+    {
+      "enabled": true,
+      "time": 480, // 8 am
+      "power": 80
+    },
+    {
+      "enabled": true,
+      "time": 600, // 10 am
+      "power": 100
+    },
+    {
+      "enabled": true,
+      "time": 960, // 4pm
+      "power": 50
+    }
+  ],
+  "thursday": [
+    {
+      "enabled": true,
+      "time": 480, // 8 am
+      "power": 100
+    }
+  ]
+  // omitted days mean that they contain no schedules
+}
+```
+
+Note:
+
+- Each day can have up to 4 schedule entries
+- `enabled` means whether this schedule element is enabled.
+- `time` is the start time of the schedule, expressed in number of minutes from midnight.
+- `power` a value of zero means demand power is disabled at the time defined by the `time` element.
+- When there are no schedules defined for the current day/time, it is believed that the settings from the previous schedule will apply, bearing in mind that it is a weekly recurring schedule.
+  This is ultimately determined by the logic in the unit itself, and not controlled by the Binding.
+
 ## Full Example
 
 daikin.things:
@@ -135,7 +267,10 @@ String DaikinACUnit_Fan { channel="daikin:ac_unit:living_room_ac:fanspeed" }
 String DaikinACUnit_Fan_Movement { channel="daikin:ac_unit:living_room_ac:fandir" }
 Number:Temperature DaikinACUnit_IndoorTemperature { channel="daikin:ac_unit:living_room_ac:indoortemp" }
 Number:Temperature DaikinACUnit_OutdoorTemperature { channel="daikin:ac_unit:living_room_ac:outdoortemp" }
-
+// Demand control, when supported by the unit
+String DaikinACUnit_DemandControl_Mode { channel="daikin:ac_unit:living_room_ac:demandcontrolmode" }
+Dimmer DaikinACUnit_DemandControl_MaxPower { channel="daikin:ac_unit:living_room_ac:demandcontrolmaxpower" }
+String DaikinACUnit_DemandControl_Schedule { channel="daikin:ac_unit:living_room_ac:demandcontrolschedule" }
 
 // for Airbase (BRP15B61)
 Switch DaikinACUnit_Power { channel="daikin:airbase_ac_unit:living_room_ac:power" }
@@ -153,7 +288,6 @@ Switch DaikinACUnit_Zone5 { channel="daikin:airbase_ac_unit:living_room_ac:zone5
 Switch DaikinACUnit_Zone6 { channel="daikin:airbase_ac_unit:living_room_ac:zone6" }
 Switch DaikinACUnit_Zone7 { channel="daikin:airbase_ac_unit:living_room_ac:zone7" }
 Switch DaikinACUnit_Zone8 { channel="daikin:airbase_ac_unit:living_room_ac:zone8" }
-
 ```
 
 daikin.sitemap:
@@ -183,5 +317,4 @@ Switch item=DaikinACUnit_Zone5 visibility=[DaikinACUnit_Power==ON]
 Switch item=DaikinACUnit_Zone6 visibility=[DaikinACUnit_Power==ON]
 Switch item=DaikinACUnit_Zone7 visibility=[DaikinACUnit_Power==ON]
 Switch item=DaikinACUnit_Zone8 visibility=[DaikinACUnit_Power==ON]
-
 ```
index a8b54f309be298848e6f8f6e05673aaf114fcada..d83ff2a75e2d3c3031f47088e6254e9bb9410873 100644 (file)
@@ -64,6 +64,10 @@ public class DaikinBindingConstants {
     public static final String CHANNEL_AC_SPECIALMODE = "specialmode";
     public static final String CHANNEL_AC_STREAMER = "streamer";
 
+    public static final String CHANNEL_AC_DEMAND_MODE = "demandcontrolmode";
+    public static final String CHANNEL_AC_DEMAND_MAX_POWER = "demandcontrolmaxpower";
+    public static final String CHANNEL_AC_DEMAND_SCHEDULE = "demandcontrolschedule";
+
     // additional channels for Airbase Controller
     public static final String CHANNEL_AIRBASE_AC_FAN_SPEED = "airbasefanspeed";
     public static final String CHANNEL_AIRBASE_AC_ZONE = "zone";
index d8517c3f6ed19bfc801ccdb1233c06aa30b0010d..9228d538e8064afdc80116dcb07e238d40927ecc 100644 (file)
@@ -29,6 +29,7 @@ import org.eclipse.jetty.http.HttpMethod;
 import org.eclipse.jetty.http.HttpStatus;
 import org.openhab.binding.daikin.internal.api.BasicInfo;
 import org.openhab.binding.daikin.internal.api.ControlInfo;
+import org.openhab.binding.daikin.internal.api.DemandControl;
 import org.openhab.binding.daikin.internal.api.EnergyInfoDayAndWeek;
 import org.openhab.binding.daikin.internal.api.EnergyInfoYear;
 import org.openhab.binding.daikin.internal.api.Enums.SpecialMode;
@@ -62,6 +63,8 @@ public class DaikinWebTargets {
     private String getEnergyInfoYearUri;
     private String getEnergyInfoWeekUri;
     private String setSpecialModeUri;
+    private String setDemandControlUri;
+    private String getDemandControlUri;
 
     private String setAirbaseControlInfoUri;
     private String getAirbaseControlInfoUri;
@@ -90,6 +93,8 @@ public class DaikinWebTargets {
         getEnergyInfoYearUri = baseUri + "aircon/get_year_power_ex";
         getEnergyInfoWeekUri = baseUri + "aircon/get_week_power_ex";
         setSpecialModeUri = baseUri + "aircon/set_special_mode";
+        setDemandControlUri = baseUri + "aircon/set_demand_control";
+        getDemandControlUri = baseUri + "aircon/get_demand_control";
 
         // Daikin Airbase API
         getAirbaseBasicInfoUri = baseUri + "skyfi/common/basic_info";
@@ -169,6 +174,18 @@ public class DaikinWebTargets {
         }
     }
 
+    public DemandControl getDemandControl() throws DaikinCommunicationException {
+        String response = invoke(getDemandControlUri);
+        return DemandControl.parse(response);
+    }
+
+    public boolean setDemandControl(DemandControl info) throws DaikinCommunicationException {
+        Map<String, String> queryParams = info.getParamString();
+        String result = invoke(setDemandControlUri, queryParams);
+        Map<String, String> responseMap = InfoParser.parse(result);
+        return Optional.ofNullable(responseMap.get("ret")).orElse("").equals("OK");
+    }
+
     // Daikin Airbase API
     public AirbaseControlInfo getAirbaseControlInfo() throws DaikinCommunicationException {
         String response = invoke(getAirbaseControlInfoUri);
diff --git a/bundles/org.openhab.binding.daikin/src/main/java/org/openhab/binding/daikin/internal/api/DemandControl.java b/bundles/org.openhab.binding.daikin/src/main/java/org/openhab/binding/daikin/internal/api/DemandControl.java
new file mode 100644 (file)
index 0000000..6b25b2f
--- /dev/null
@@ -0,0 +1,187 @@
+/**
+ * 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.daikin.internal.api;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.daikin.internal.api.Enums.DemandControlMode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * Class for holding the set of parameters used by set and get demand control info.
+ *
+ * @author Jimmy Tanagra - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class DemandControl {
+    private static final Logger LOGGER = LoggerFactory.getLogger(DemandControl.class);
+
+    private static final List<String> DAYS = List.of("monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
+            "sunday");
+    // create a map of "monday" -> "mo", "tuesday" -> "tu", etc.
+    private static final Map<String, String> DAYS_ABBREVIATIONS = DAYS.stream()
+            .map(day -> Map.entry(day, day.substring(0, 2)))
+            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+    private static Gson GSON = new Gson();
+
+    public String ret = "";
+
+    public DemandControlMode mode = DemandControlMode.AUTO;
+    public int maxPower = 100;
+    private Map<String, List<ScheduleEntry>> scheduleMap = new HashMap<>();
+
+    private DemandControl() {
+    }
+
+    public String getSchedule() {
+        return GSON.toJson(scheduleMap);
+    }
+
+    public void setSchedule(String schedule) throws JsonSyntaxException {
+        Map<String, List<ScheduleEntry>> parsedMap = GSON.fromJson(schedule,
+                new TypeToken<Map<String, List<ScheduleEntry>>>() {
+                }.getType());
+
+        if (DAYS.containsAll(parsedMap.keySet())) {
+            scheduleMap = parsedMap;
+        } else {
+            throw new JsonSyntaxException("Invalid day(s) in JSON data");
+        }
+    }
+
+    public int getScheduledMaxPower() {
+        return getScheduledMaxPower(LocalDateTime.now());
+    }
+
+    // Returns the current max_power setting based on the schedule
+    // If there are no matching schedules for the current time,
+    // it will search the last schedule of the previous non-empty day
+    public int getScheduledMaxPower(LocalDateTime dateTime) {
+        int todayIndex = dateTime.getDayOfWeek().getValue() - 1;
+        String today = DAYS.get(todayIndex);
+        int currentMinsFromMidnight = dateTime.toLocalTime().toSecondOfDay() / 60;
+
+        // search today's schedule for the last applicable schedule
+        Optional<Integer> maxPower = scheduleMap.get(today).stream().filter(entry -> entry.enabled)
+                .sorted((s1, s2) -> Integer.compare(s1.time, s2.time))
+                .takeWhile(scheduleEntry -> scheduleEntry.time <= currentMinsFromMidnight)
+                .reduce((first, second) -> second) // get the last entry that matches the condition
+                .map(scheduleEntry -> scheduleEntry.power).or(() -> {
+                    // there are no matching schedules today, so
+                    // get the last entry of the previous non-empty schedule day,
+                    // wrapping around the DAYS array if necessary
+
+                    int currentIndex = todayIndex > 0 ? (todayIndex - 1) : (DAYS.size() - 1);
+                    while (currentIndex != todayIndex) {
+                        String prevDay = DAYS.get(currentIndex);
+                        List<ScheduleEntry> prevDaySchedules = scheduleMap.get(prevDay).stream()
+                                .filter(entry -> entry.enabled).sorted((s1, s2) -> Integer.compare(s1.time, s2.time))
+                                .toList();
+                        if (!prevDaySchedules.isEmpty()) {
+                            return Optional.of(prevDaySchedules.get(prevDaySchedules.size() - 1).power);
+                        }
+                        currentIndex = currentIndex > 0 ? (currentIndex - 1) : (DAYS.size() - 1);
+                    }
+
+                    // if previous days have no schedules, use today's last schedule if any
+                    return scheduleMap.get(today).stream().filter(entry -> entry.enabled)
+                            .sorted((s1, s2) -> Integer.compare(s1.time, s2.time)).reduce((first, second) -> second)
+                            .map(scheduleEntry -> scheduleEntry.power);
+                });
+
+        return maxPower.map(value -> value == 0 ? 100 : value) // a maxPower of 0 means the demand control is disabled,
+                                                               // so return 100
+                .orElse(100); // return 100 also for no schedules
+    }
+
+    public static DemandControl parse(String response) {
+        LOGGER.trace("Parsing string: \"{}\"", response);
+
+        Map<String, String> responseMap = InfoParser.parse(response);
+
+        DemandControl info = new DemandControl();
+        info.ret = responseMap.getOrDefault("ret", "");
+        boolean enabled = "1".equals(responseMap.get("en_demand"));
+        if (!enabled) {
+            info.mode = DemandControlMode.OFF;
+        } else {
+            info.mode = DemandControlMode.fromValue(responseMap.getOrDefault("mode", "-"));
+        }
+        info.maxPower = Objects.requireNonNull(Optional.ofNullable(responseMap.get("max_pow"))
+                .flatMap(value -> InfoParser.parseInt(value)).orElse(100));
+
+        info.scheduleMap = DAYS_ABBREVIATIONS.entrySet().stream().map(day -> {
+            final String dayName = day.getKey();
+            final String dayPrefix = day.getValue();
+
+            final int dayCount = Objects.requireNonNull(Optional.ofNullable(responseMap.get(dayPrefix + "c"))
+                    .flatMap(value -> InfoParser.parseInt(value)).orElse(0));
+
+            // We don't want to sort the entries by time here, to preserve the same order from the response
+            List<ScheduleEntry> schedules = Stream.iterate(1, i -> i <= dayCount, i -> i + 1).map(i -> {
+                String prefix = dayPrefix + i + "_";
+                return new ScheduleEntry("1".equals(responseMap.get(prefix + "en")),
+                        Objects.requireNonNull(Optional.ofNullable(responseMap.get(prefix + "t"))
+                                .flatMap(value -> InfoParser.parseInt(value)).orElse(0)),
+                        Objects.requireNonNull(Optional.ofNullable(responseMap.get(prefix + "p"))
+                                .flatMap(value -> InfoParser.parseInt(value)).orElse(0)));
+            }).toList();
+
+            return Map.entry(dayName, schedules);
+        }).collect(Collectors.toMap(entry -> entry.getKey(), entry -> entry.getValue()));
+
+        return info;
+    }
+
+    public Map<String, String> getParamString() {
+        Map<String, String> params = new HashMap<>();
+        params.put("en_demand", mode == DemandControlMode.OFF ? "0" : "1");
+        if (mode != DemandControlMode.OFF) {
+            params.put("mode", mode.getValue());
+            params.put("max_pow", Integer.toString(maxPower));
+            DAYS.stream().forEach(day -> {
+                String dayPrefix = DAYS_ABBREVIATIONS.get(day);
+                List<ScheduleEntry> schedules = scheduleMap.getOrDefault(day, List.of());
+                params.put(dayPrefix + "c", Integer.toString(schedules.size()));
+                for (int i = 0; i < schedules.size(); i++) {
+                    ScheduleEntry schedule = schedules.get(i);
+                    String prefix = dayPrefix + (i + 1) + "_";
+                    params.put(prefix + "en", schedule.enabled ? "1" : "0");
+                    params.put(prefix + "t", Integer.toString(schedule.time));
+                    params.put(prefix + "p", Integer.toString(schedule.power));
+                }
+            });
+        }
+
+        return params;
+    }
+
+    // package private for testing
+    record ScheduleEntry(boolean enabled, int time, int power) {
+    }
+}
index 752c028db8533f6b60912d01908ab1faeceb136a..3b1918bc80dca9869667dfcd1c38ff9d53d28bcb 100644 (file)
@@ -209,4 +209,43 @@ public class Enums {
             return NORMAL;
         }
     }
+
+    public enum DemandControlMode {
+        OFF("-"),
+        MANUAL("0"),
+        SCHEDULED("1"),
+        AUTO("2");
+
+        private final String value;
+        private static final Logger LOGGER = LoggerFactory.getLogger(DemandControlMode.class);
+
+        DemandControlMode(String value) {
+            this.value = value;
+        }
+
+        public String getValue() {
+            return value;
+        }
+
+        public static boolean isValidValue(String value) {
+            for (DemandControlMode m : DemandControlMode.values()) {
+                if (m.getValue().equals(value)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        public static DemandControlMode fromValue(String value) {
+            for (DemandControlMode m : DemandControlMode.values()) {
+                if (m.getValue().equals(value)) {
+                    return m;
+                }
+            }
+            LOGGER.debug("Unexpected DemandControlMode value of \"{}\"", value);
+
+            // Default to off
+            return OFF;
+        }
+    }
 }
index 21bab3dda4a278401c1e19506d6ff445de56e042..3b34ad5642649cdb9c9dad5b6dbab64b817006ef 100644 (file)
@@ -24,8 +24,10 @@ import org.openhab.binding.daikin.internal.DaikinBindingConstants;
 import org.openhab.binding.daikin.internal.DaikinCommunicationException;
 import org.openhab.binding.daikin.internal.DaikinDynamicStateDescriptionProvider;
 import org.openhab.binding.daikin.internal.api.ControlInfo;
+import org.openhab.binding.daikin.internal.api.DemandControl;
 import org.openhab.binding.daikin.internal.api.EnergyInfoDayAndWeek;
 import org.openhab.binding.daikin.internal.api.EnergyInfoYear;
+import org.openhab.binding.daikin.internal.api.Enums.DemandControlMode;
 import org.openhab.binding.daikin.internal.api.Enums.FanMovement;
 import org.openhab.binding.daikin.internal.api.Enums.FanSpeed;
 import org.openhab.binding.daikin.internal.api.Enums.HomekitMode;
@@ -34,6 +36,7 @@ import org.openhab.binding.daikin.internal.api.Enums.SpecialMode;
 import org.openhab.binding.daikin.internal.api.SensorInfo;
 import org.openhab.core.library.types.DecimalType;
 import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
 import org.openhab.core.library.types.QuantityType;
 import org.openhab.core.library.types.StringType;
 import org.openhab.core.library.unit.Units;
@@ -45,6 +48,8 @@ import org.openhab.core.types.UnDefType;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.google.gson.JsonSyntaxException;
+
 /**
  * Handles communicating with a Daikin air conditioning unit.
  *
@@ -59,6 +64,9 @@ public class DaikinAcUnitHandler extends DaikinBaseHandler {
     private final Logger logger = LoggerFactory.getLogger(DaikinAcUnitHandler.class);
 
     private Optional<Integer> autoModeValue = Optional.empty();
+    private boolean pollDemandControl = true;
+    private Optional<String> savedDemandControlSchedule = Optional.empty();
+    private Optional<Integer> savedDemandControlMaxPower = Optional.empty();
 
     public DaikinAcUnitHandler(Thing thing, DaikinDynamicStateDescriptionProvider stateDescriptionProvider,
             @Nullable HttpClient httpClient) {
@@ -153,6 +161,29 @@ public class DaikinAcUnitHandler extends DaikinBaseHandler {
             // Suppress any error if energy info is not supported.
             logger.debug("getEnergyInfoDayAndWeek() error: {}", e.getMessage());
         }
+
+        if (pollDemandControl) {
+            try {
+                DemandControl demandInfo = webTargets.getDemandControl();
+                String schedule = demandInfo.getSchedule();
+                int maxPower = demandInfo.maxPower;
+
+                if (demandInfo.mode == DemandControlMode.SCHEDULED) {
+                    savedDemandControlSchedule = Optional.of(schedule);
+                    maxPower = demandInfo.getScheduledMaxPower();
+                } else if (demandInfo.mode == DemandControlMode.MANUAL) {
+                    savedDemandControlMaxPower = Optional.of(demandInfo.maxPower);
+                }
+
+                updateState(DaikinBindingConstants.CHANNEL_AC_DEMAND_MODE, new StringType(demandInfo.mode.name()));
+                updateState(DaikinBindingConstants.CHANNEL_AC_DEMAND_MAX_POWER, new PercentType(maxPower));
+                updateState(DaikinBindingConstants.CHANNEL_AC_DEMAND_SCHEDULE, new StringType(schedule));
+            } catch (DaikinCommunicationException e) {
+                // Suppress any error if demand control is not supported.
+                logger.debug("getDemandControl() error: {}", e.getMessage());
+                pollDemandControl = false;
+            }
+        }
     }
 
     @Override
@@ -177,6 +208,24 @@ public class DaikinAcUnitHandler extends DaikinBaseHandler {
                     return true;
                 }
                 break;
+            case DaikinBindingConstants.CHANNEL_AC_DEMAND_MODE:
+                if (command instanceof StringType stringCommand) {
+                    changeDemandMode(stringCommand.toString());
+                    return true;
+                }
+                break;
+            case DaikinBindingConstants.CHANNEL_AC_DEMAND_MAX_POWER:
+                if (command instanceof PercentType percentCommand) {
+                    changeDemandMaxPower(percentCommand.intValue());
+                    return true;
+                }
+                break;
+            case DaikinBindingConstants.CHANNEL_AC_DEMAND_SCHEDULE:
+                if (command instanceof StringType stringCommand) {
+                    changeDemandSchedule(stringCommand.toString());
+                    return true;
+                }
+                break;
         }
         return false;
     }
@@ -265,6 +314,51 @@ public class DaikinAcUnitHandler extends DaikinBaseHandler {
         webTargets.setStreamerMode(streamerMode);
     }
 
+    protected void changeDemandMode(String mode) throws DaikinCommunicationException {
+        DemandControlMode newMode;
+        try {
+            newMode = DemandControlMode.valueOf(mode);
+        } catch (IllegalArgumentException e) {
+            logger.warn("Invalid demand mode: {}. Valid values: {}", mode, DemandControlMode.values());
+            return;
+        }
+        DemandControl demandInfo = webTargets.getDemandControl();
+        if (demandInfo.mode != newMode) {
+            if (newMode == DemandControlMode.SCHEDULED && savedDemandControlSchedule.isPresent()) {
+                // restore previously saved schedule
+                demandInfo.setSchedule(savedDemandControlSchedule.get());
+            }
+
+            if (newMode == DemandControlMode.MANUAL && savedDemandControlMaxPower.isPresent()) {
+                // restore previously saved maxPower
+                demandInfo.maxPower = savedDemandControlMaxPower.get();
+            }
+        }
+        demandInfo.mode = newMode;
+        webTargets.setDemandControl(demandInfo);
+    }
+
+    protected void changeDemandMaxPower(int maxPower) throws DaikinCommunicationException {
+        DemandControl demandInfo = webTargets.getDemandControl();
+        demandInfo.mode = DemandControlMode.MANUAL;
+        demandInfo.maxPower = maxPower;
+        webTargets.setDemandControl(demandInfo);
+        savedDemandControlMaxPower = Optional.of(maxPower);
+    }
+
+    protected void changeDemandSchedule(String schedule) throws DaikinCommunicationException {
+        DemandControl demandInfo = webTargets.getDemandControl();
+        try {
+            demandInfo.setSchedule(schedule);
+        } catch (JsonSyntaxException e) {
+            logger.warn("Invalid schedule: {}. {}", schedule, e.getMessage());
+            return;
+        }
+        demandInfo.mode = DemandControlMode.SCHEDULED;
+        webTargets.setDemandControl(demandInfo);
+        savedDemandControlSchedule = Optional.of(demandInfo.getSchedule());
+    }
+
     /**
      * Updates energy year channels. Values are provided in hundreds of Watt
      *
index 368bda9c582b0c92f5a182a72374cb14608ab856..4c73873687b1c8ea672eb31ccf8160c8752378ac 100644 (file)
@@ -27,8 +27,24 @@ thing-type.config.daikin.config.uuid.description = A unique UUID for authenticat
 
 channel-type.daikin.acunit-cmpfrequency.label = Compressor Frequency
 channel-type.daikin.acunit-cmpfrequency.description = Current compressor frequency
+channel-type.daikin.acunit-demandcontrolmaxpower.label = Demand Control Max Power
+channel-type.daikin.acunit-demandcontrolmaxpower.description = The maximum power for demand control in percent. Allowed range is between 40% and 100% in increments of 5%.
+channel-type.daikin.acunit-demandcontrolmode.label = Demand Control Mode
+channel-type.daikin.acunit-demandcontrolmode.description = The demand control mode
+channel-type.daikin.acunit-demandcontrolmode.state.option.OFF = Off
+channel-type.daikin.acunit-demandcontrolmode.state.option.AUTO = Auto
+channel-type.daikin.acunit-demandcontrolmode.state.option.SCHEDULED = Scheduled
+channel-type.daikin.acunit-demandcontrolmode.state.option.MANUAL = Manual
+channel-type.daikin.acunit-demandcontrolschedule.label = Demand Control Schedule
+channel-type.daikin.acunit-demandcontrolschedule.description = The demand control schedule in JSON format.
 channel-type.daikin.acunit-energycoolingcurrentyear-1.label = Energy Cooling Current Year January
 channel-type.daikin.acunit-energycoolingcurrentyear-1.description = The energy usage for cooling this year January
+channel-type.daikin.acunit-energycoolingcurrentyear-10.label = Energy Cooling Current Year October
+channel-type.daikin.acunit-energycoolingcurrentyear-10.description = The energy usage for cooling this year October
+channel-type.daikin.acunit-energycoolingcurrentyear-11.label = Energy Cooling Current Year November
+channel-type.daikin.acunit-energycoolingcurrentyear-11.description = The energy usage for cooling this year November
+channel-type.daikin.acunit-energycoolingcurrentyear-12.label = Energy Cooling Current Year December
+channel-type.daikin.acunit-energycoolingcurrentyear-12.description = The energy usage for cooling this year December
 channel-type.daikin.acunit-energycoolingcurrentyear-2.label = Energy Cooling Current Year February
 channel-type.daikin.acunit-energycoolingcurrentyear-2.description = The energy usage for cooling this year February
 channel-type.daikin.acunit-energycoolingcurrentyear-3.label = Energy Cooling Current Year March
@@ -45,12 +61,6 @@ channel-type.daikin.acunit-energycoolingcurrentyear-8.label = Energy Cooling Cur
 channel-type.daikin.acunit-energycoolingcurrentyear-8.description = The energy usage for cooling this year August
 channel-type.daikin.acunit-energycoolingcurrentyear-9.label = Energy Cooling Current Year September
 channel-type.daikin.acunit-energycoolingcurrentyear-9.description = The energy usage for cooling this year September
-channel-type.daikin.acunit-energycoolingcurrentyear-10.label = Energy Cooling Current Year October
-channel-type.daikin.acunit-energycoolingcurrentyear-10.description = The energy usage for cooling this year October
-channel-type.daikin.acunit-energycoolingcurrentyear-11.label = Energy Cooling Current Year November
-channel-type.daikin.acunit-energycoolingcurrentyear-11.description = The energy usage for cooling this year November
-channel-type.daikin.acunit-energycoolingcurrentyear-12.label = Energy Cooling Current Year December
-channel-type.daikin.acunit-energycoolingcurrentyear-12.description = The energy usage for cooling this year December
 channel-type.daikin.acunit-energycoolinglastweek.label = Energy Cooling Last Week
 channel-type.daikin.acunit-energycoolinglastweek.description = The energy usage for cooling last week
 channel-type.daikin.acunit-energycoolingthisweek.label = Energy Cooling This Week
@@ -59,6 +69,12 @@ channel-type.daikin.acunit-energycoolingtoday.label = Energy Cooling Today
 channel-type.daikin.acunit-energycoolingtoday.description = The energy usage for cooling today
 channel-type.daikin.acunit-energyheatingcurrentyear-1.label = Energy Heating Current Year January
 channel-type.daikin.acunit-energyheatingcurrentyear-1.description = The energy usage for heating this year January
+channel-type.daikin.acunit-energyheatingcurrentyear-10.label = Energy Heating Current Year October
+channel-type.daikin.acunit-energyheatingcurrentyear-10.description = The energy usage for heating this year October
+channel-type.daikin.acunit-energyheatingcurrentyear-11.label = Energy Heating Current Year November
+channel-type.daikin.acunit-energyheatingcurrentyear-11.description = The energy usage for heating this year November
+channel-type.daikin.acunit-energyheatingcurrentyear-12.label = Energy Heating Current Year December
+channel-type.daikin.acunit-energyheatingcurrentyear-12.description = The energy usage for heating this year December
 channel-type.daikin.acunit-energyheatingcurrentyear-2.label = Energy Heating Current Year February
 channel-type.daikin.acunit-energyheatingcurrentyear-2.description = The energy usage for heating this year February
 channel-type.daikin.acunit-energyheatingcurrentyear-3.label = Energy Heating Current Year March
@@ -75,12 +91,6 @@ channel-type.daikin.acunit-energyheatingcurrentyear-8.label = Energy Heating Cur
 channel-type.daikin.acunit-energyheatingcurrentyear-8.description = The energy usage for heating this year August
 channel-type.daikin.acunit-energyheatingcurrentyear-9.label = Energy Heating Current Year September
 channel-type.daikin.acunit-energyheatingcurrentyear-9.description = The energy usage for heating this year September
-channel-type.daikin.acunit-energyheatingcurrentyear-10.label = Energy Heating Current Year October
-channel-type.daikin.acunit-energyheatingcurrentyear-10.description = The energy usage for heating this year October
-channel-type.daikin.acunit-energyheatingcurrentyear-11.label = Energy Heating Current Year November
-channel-type.daikin.acunit-energyheatingcurrentyear-11.description = The energy usage for heating this year November
-channel-type.daikin.acunit-energyheatingcurrentyear-12.label = Energy Heating Current Year December
-channel-type.daikin.acunit-energyheatingcurrentyear-12.description = The energy usage for heating this year December
 channel-type.daikin.acunit-energyheatinglastweek.label = Energy Heating Last Week
 channel-type.daikin.acunit-energyheatinglastweek.description = The energy usage for heating last week
 channel-type.daikin.acunit-energyheatingthisweek.label = Energy Heating This Week
index c8e6c58e87dc8ff0bb589d136c51fb42d0301acc..64f1a37570d11caec1345478016d5c03df094dcb 100644 (file)
                        <channel id="energycoolingcurrentyear-10" typeId="acunit-energycoolingcurrentyear-10"></channel>
                        <channel id="energycoolingcurrentyear-11" typeId="acunit-energycoolingcurrentyear-11"></channel>
                        <channel id="energycoolingcurrentyear-12" typeId="acunit-energycoolingcurrentyear-12"></channel>
+                       <channel id="demandcontrolmode" typeId="acunit-demandcontrolmode"></channel>
+                       <channel id="demandcontrolmaxpower" typeId="acunit-demandcontrolmaxpower"></channel>
+                       <channel id="demandcontrolschedule" typeId="acunit-demandcontrolschedule"></channel>
                </channels>
 
+               <properties>
+                       <property name="thingTypeVersion">1</property>
+               </properties>
                <representation-property>host</representation-property>
                <config-description-ref uri="thing-type:daikin:config"/>
        </thing-type>
                <state readOnly="true" pattern="%.1f %unit%"/>
        </channel-type>
 
+       <channel-type id="acunit-demandcontrolmode" advanced="true">
+               <item-type>String</item-type>
+               <label>Demand Control Mode</label>
+               <description>The demand control mode</description>
+               <state>
+                       <options>
+                               <option value="OFF">Off</option>
+                               <option value="AUTO">Auto</option>
+                               <option value="SCHEDULED">Scheduled</option>
+                               <option value="MANUAL">Manual</option>
+                       </options>
+               </state>
+       </channel-type>
+       <channel-type id="acunit-demandcontrolmaxpower" advanced="true">
+               <item-type>Dimmer</item-type>
+               <label>Demand Control Max Power</label>
+               <description>The maximum power for demand control in percent. Allowed range is between 40% and 100% in increments of
+                       5%.</description>
+               <state pattern="%d %%" min="40" max="100" step="5"></state>
+       </channel-type>
+       <channel-type id="acunit-demandcontrolschedule" advanced="true">
+               <item-type>String</item-type>
+               <label>Demand Control Schedule</label>
+               <description>The demand control schedule in JSON format.</description>
+       </channel-type>
+
        <channel-type id="airbase-acunit-fan">
                <item-type>String</item-type>
                <label>Fan</label>
diff --git a/bundles/org.openhab.binding.daikin/src/main/resources/OH-INF/update/update.xml b/bundles/org.openhab.binding.daikin/src/main/resources/OH-INF/update/update.xml
new file mode 100644 (file)
index 0000000..4a3377c
--- /dev/null
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<update:update-descriptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:update="https://openhab.org/schemas/update-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/update-description/v1.0.0 https://openhab.org/schemas/update-description-1.0.0.xsd">
+
+       <thing-type uid="daikin:ac_unit">
+               <instruction-set targetVersion="1">
+                       <add-channel id="demandcontrolmode">
+                               <type>daikin:acunit-demandcontrolmode</type>
+                       </add-channel>
+                       <add-channel id="demandcontrolmaxpower">
+                               <type>daikin:acunit-demandcontrolmaxpower</type>
+                       </add-channel>
+                       <add-channel id="demandcontrolschedule">
+                               <type>daikin:acunit-demandcontrolschedule</type>
+                       </add-channel>
+               </instruction-set>
+       </thing-type>
+
+</update:update-descriptions>
index 25ae882a2a476f7c8a799971dc93da4abc78fe02..2b0cce6a1933d36d6678ff92ff2cce2c9da65b23 100644 (file)
@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test;
 import org.openhab.binding.daikin.internal.api.Enums.FanMovement;
 
 /**
- * This class provides tests for deconz lights
+ * This class provides tests for the ControlInfo class
  *
  * @author Leo Siepel - Initial contribution
  *
diff --git a/bundles/org.openhab.binding.daikin/src/test/java/org/openhab/binding/daikin/internal/api/DemandControlTest.java b/bundles/org.openhab.binding.daikin/src/test/java/org/openhab/binding/daikin/internal/api/DemandControlTest.java
new file mode 100644 (file)
index 0000000..e213af5
--- /dev/null
@@ -0,0 +1,388 @@
+/**
+ * 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.daikin.internal.api;
+
+import static java.time.DayOfWeek.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasEntry;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.time.DayOfWeek;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.openhab.binding.daikin.internal.api.DemandControl.ScheduleEntry;
+import org.openhab.binding.daikin.internal.api.Enums.DemandControlMode;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * This class provides tests for the DemandControl class
+ *
+ * @author Jimmy Tanagra - Initial contribution
+ *
+ */
+
+@NonNullByDefault
+public class DemandControlTest {
+
+    public static Stream<Arguments> parserTest() {
+        return Stream.of( //
+                Arguments.of("ret=OK,type=1,en_demand=0,mode=0,max_pow=100", DemandControlMode.OFF, 100),
+                Arguments.of("ret=OK,type=1,en_demand=1,mode=0,max_pow=100", DemandControlMode.MANUAL, 100),
+                Arguments.of("ret=OK,type=1,en_demand=0,mode=1,max_pow=100", DemandControlMode.OFF, 100),
+                Arguments.of("ret=OK,type=1,en_demand=1,mode=1,max_pow=100", DemandControlMode.SCHEDULED, 100),
+                Arguments.of("ret=OK,type=1,en_demand=0,mode=2,max_pow=100", DemandControlMode.OFF, 100),
+                Arguments.of("ret=OK,type=1,en_demand=1,mode=2,max_pow=100", DemandControlMode.AUTO, 100),
+                Arguments.of("ret=OK,type=1,en_demand=0,mode=0,max_pow=50", DemandControlMode.OFF, 50),
+                Arguments.of("ret=OK,type=1,en_demand=0,mode=0,max_pow=40", DemandControlMode.OFF, 40),
+
+                // Invalid inputs - defaults
+                Arguments.of("ret=OK,type=1,en_demand=,mode=,max_pow=", DemandControlMode.OFF, 100)
+        //
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource
+    public void parserTest(String input, DemandControlMode expectedMode, int expectedMaxPower) {
+        DemandControl info = DemandControl.parse(input);
+
+        // assert
+        assertEquals(expectedMode, info.mode);
+        assertEquals(expectedMaxPower, info.maxPower);
+    }
+
+    public static Stream<Arguments> inputScheduleParserTest() {
+        return Stream.of( //
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=0,mode=0,max_pow=100,scdl_per_day=4,moc=0,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        Map.of("monday", List.of(), "tuesday", List.of(), "wednesday", List.of(), "thursday", List.of(),
+                                "friday", List.of(), "saturday", List.of(), "sunday", List.of())),
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,moc=3,mo1_en=1,mo1_t=720,mo1_p=90,mo2_en=1,mo2_t=840,mo2_p=0,mo3_en=1,mo3_t=600,mo3_p=70,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        Map.of("monday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70)),
+                                "tuesday", List.of(), "wednesday", List.of(), "thursday", List.of(), "friday",
+                                List.of(), "saturday", List.of(), "sunday", List.of())),
+                // added mo4_xxx but moc=3
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,moc=3,mo1_en=1,mo1_t=720,mo1_p=90,mo2_en=1,mo2_t=840,mo2_p=0,mo3_en=1,mo3_t=600,mo3_p=70,mo4_en=0,mo4_t=30,mo4_p=0",
+                        Map.of("monday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70)),
+                                "tuesday", List.of(), "wednesday", List.of(), "thursday", List.of(), "friday",
+                                List.of(), "saturday", List.of(), "sunday", List.of())),
+                // this time moc=4
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,moc=4,mo1_en=1,mo1_t=720,mo1_p=90,mo2_en=1,mo2_t=840,mo2_p=0,mo3_en=1,mo3_t=600,mo3_p=70,mo4_en=0,mo4_t=30,mo4_p=0",
+                        Map.of("monday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70), new ScheduleEntry(false, 30, 0)),
+                                "tuesday", List.of(), "wednesday", List.of(), "thursday", List.of(), "friday",
+                                List.of(), "saturday", List.of(), "sunday", List.of())),
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,tuc=4,tu1_en=1,tu1_t=720,tu1_p=90,tu2_en=1,tu2_t=840,tu2_p=0,tu3_en=1,tu3_t=600,tu3_p=70,tu4_en=0,tu4_t=30,tu4_p=0",
+                        Map.of("tuesday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70), new ScheduleEntry(false, 30, 0)),
+                                "monday", List.of(), "wednesday", List.of(), "thursday", List.of(), "friday", List.of(),
+                                "saturday", List.of(), "sunday", List.of())),
+
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,wec=4,we1_en=1,we1_t=720,we1_p=90,we2_en=1,we2_t=840,we2_p=0,we3_en=1,we3_t=600,we3_p=70,we4_en=0,we4_t=30,we4_p=0",
+                        Map.of("wednesday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70), new ScheduleEntry(false, 30, 0)),
+                                "monday", List.of(), "tuesday", List.of(), "thursday", List.of(), "friday", List.of(),
+                                "saturday", List.of(), "sunday", List.of())),
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,thc=4,th1_en=1,th1_t=720,th1_p=90,th2_en=1,th2_t=840,th2_p=0,th3_en=1,th3_t=600,th3_p=70,th4_en=0,th4_t=30,th4_p=0",
+                        Map.of("thursday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70), new ScheduleEntry(false, 30, 0)),
+                                "monday", List.of(), "tuesday", List.of(), "wednesday", List.of(), "friday", List.of(),
+                                "saturday", List.of(), "sunday", List.of())),
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,frc=4,fr1_en=1,fr1_t=720,fr1_p=90,fr2_en=1,fr2_t=840,fr2_p=0,fr3_en=1,fr3_t=600,fr3_p=70,fr4_en=0,fr4_t=30,fr4_p=0",
+                        Map.of("friday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70), new ScheduleEntry(false, 30, 0)),
+                                "monday", List.of(), "tuesday", List.of(), "wednesday", List.of(), "thursday",
+                                List.of(), "saturday", List.of(), "sunday", List.of())),
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,sac=4,sa1_en=1,sa1_t=720,sa1_p=90,sa2_en=1,sa2_t=840,sa2_p=0,sa3_en=1,sa3_t=600,sa3_p=70,sa4_en=0,sa4_t=30,sa4_p=0",
+                        Map.of("saturday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70), new ScheduleEntry(false, 30, 0)),
+                                "monday", List.of(), "tuesday", List.of(), "wednesday", List.of(), "thursday",
+                                List.of(), "friday", List.of(), "sunday", List.of())),
+                Arguments.of(
+                        "ret=OK,type=1,en_demand=1,mode=1,max_pow=100,scdl_per_day=4,suc=4,su1_en=1,su1_t=720,su1_p=90,su2_en=1,su2_t=840,su2_p=0,su3_en=1,su3_t=600,su3_p=70,su4_en=0,su4_t=30,su4_p=0",
+                        Map.of("sunday",
+                                List.of(new ScheduleEntry(true, 720, 90), new ScheduleEntry(true, 840, 0),
+                                        new ScheduleEntry(true, 600, 70), new ScheduleEntry(false, 30, 0)),
+                                "monday", List.of(), "tuesday", List.of(), "wednesday", List.of(), "thursday",
+                                List.of(), "friday", List.of(), "saturday", List.of()))
+
+        //
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource
+    public void inputScheduleParserTest(String input, Map<String, List<ScheduleEntry>> expectedSchedule) {
+        DemandControl info = DemandControl.parse(input);
+
+        var parsedJsonObject = parseJson(info.getSchedule());
+
+        String expectedJsonString = new Gson().toJson(expectedSchedule);
+        var expectedJsonObject = parseJson(expectedJsonString);
+
+        // assert
+        assertEquals(expectedJsonObject, parsedJsonObject);
+    }
+
+    public static Stream<Arguments> jsonScheduleToParamStringTest() {
+        return Stream.of( //
+                Arguments.of( //
+                        """
+                                {
+                                    "monday": [
+                                        {"enabled":true,"time":720,"power":90},
+                                        {"enabled":true,"time":840,"power":0},
+                                        {"enabled":false,"time":600,"power":70},
+                                        {"enabled":true,"time":300,"power":50}
+                                    ],
+                                    "tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]
+                                }
+                                """, //
+                        "moc=4," + //
+                                "mo1_en=1,mo1_t=720,mo1_p=90," + //
+                                "mo2_en=1,mo2_t=840,mo2_p=0," + //
+                                "mo3_en=0,mo3_t=600,mo3_p=70," + //
+                                "mo4_en=1,mo4_t=300,mo4_p=50," + //
+                                "tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0" //
+                ), //
+                Arguments.of( //
+                        """
+                                {
+                                    "tuesday": [
+                                        {"enabled":true,"time":720,"power":90},
+                                        {"enabled":true,"time":840,"power":0},
+                                        {"enabled":false,"time":600,"power":70},
+                                        {"enabled":true,"time":300,"power":50}
+                                    ],
+                                    "monday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]
+                                }
+                                """, //
+                        "tuc=4," + //
+                                "tu1_en=1,tu1_t=720,tu1_p=90," + //
+                                "tu2_en=1,tu2_t=840,tu2_p=0," + //
+                                "tu3_en=0,tu3_t=600,tu3_p=70," + //
+                                "tu4_en=1,tu4_t=300,tu4_p=50," + //
+                                "moc=0,wec=0,thc=0,frc=0,sac=0,suc=0" //
+                ), //
+                Arguments.of( //
+                        """
+                                {
+                                    "wednesday": [
+                                        {"enabled":true,"time":720,"power":90},
+                                        {"enabled":true,"time":840,"power":0},
+                                        {"enabled":false,"time":600,"power":70},
+                                        {"enabled":true,"time":300,"power":50}
+                                    ],
+                                    "monday":[],"tuesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]
+                                }
+                                """, //
+                        "wec=4," + //
+                                "we1_en=1,we1_t=720,we1_p=90," + //
+                                "we2_en=1,we2_t=840,we2_p=0," + //
+                                "we3_en=0,we3_t=600,we3_p=70," + //
+                                "we4_en=1,we4_t=300,we4_p=50," + //
+                                "moc=0,tuc=0,thc=0,frc=0,sac=0,suc=0" //
+                ), //
+                Arguments.of( //
+                        """
+                                {
+                                    "thursday": [
+                                        {"enabled":true,"time":720,"power":90},
+                                        {"enabled":true,"time":840,"power":0},
+                                        {"enabled":false,"time":600,"power":70},
+                                        {"enabled":true,"time":300,"power":50}
+                                    ],
+                                    "monday":[],"tuesday":[],"wednesday":[],"friday":[],"saturday":[],"sunday":[]
+                                }
+                                """, //
+                        "thc=4," + //
+                                "th1_en=1,th1_t=720,th1_p=90," + //
+                                "th2_en=1,th2_t=840,th2_p=0," + //
+                                "th3_en=0,th3_t=600,th3_p=70," + //
+                                "th4_en=1,th4_t=300,th4_p=50," + //
+                                "moc=0,tuc=0,wec=0,frc=0,sac=0,suc=0" //
+                ), //
+                Arguments.of( //
+                        """
+                                {
+                                    "friday": [
+                                        {"enabled":true,"time":720,"power":90},
+                                        {"enabled":true,"time":840,"power":0},
+                                        {"enabled":false,"time":600,"power":70},
+                                        {"enabled":true,"time":300,"power":50}
+                                    ],
+                                    "monday":[],"tuesday":[],"wednesday":[],"thursday":[],"saturday":[],"sunday":[]
+                                }
+                                """, //
+                        "frc=4," + //
+                                "fr1_en=1,fr1_t=720,fr1_p=90," + //
+                                "fr2_en=1,fr2_t=840,fr2_p=0," + //
+                                "fr3_en=0,fr3_t=600,fr3_p=70," + //
+                                "fr4_en=1,fr4_t=300,fr4_p=50," + //
+                                "moc=0,tuc=0,thc=0,wec=0,sac=0,suc=0" //
+                ), //
+                Arguments.of( //
+                        """
+                                {
+                                    "saturday": [
+                                        {"enabled":true,"time":720,"power":90},
+                                        {"enabled":true,"time":840,"power":0},
+                                        {"enabled":false,"time":600,"power":70},
+                                        {"enabled":true,"time":300,"power":50}
+                                    ],
+                                    "monday":[],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"sunday":[]
+                                }
+                                """, //
+                        "sac=4," + //
+                                "sa1_en=1,sa1_t=720,sa1_p=90," + //
+                                "sa2_en=1,sa2_t=840,sa2_p=0," + //
+                                "sa3_en=0,sa3_t=600,sa3_p=70," + //
+                                "sa4_en=1,sa4_t=300,sa4_p=50," + //
+                                "moc=0,tuc=0,thc=0,frc=0,wec=0,suc=0" //
+                ), //
+                Arguments.of( //
+                        """
+                                {
+                                    "sunday": [
+                                        {"enabled":true,"time":720,"power":90},
+                                        {"enabled":true,"time":840,"power":0},
+                                        {"enabled":false,"time":600,"power":70},
+                                        {"enabled":true,"time":300,"power":50}
+                                    ],
+                                    "monday":[],"tuesday":[],"thursday":[],"friday":[],"saturday":[],"wednesday":[]
+                                }
+                                """, //
+                        "suc=4," + //
+                                "su1_en=1,su1_t=720,su1_p=90," + //
+                                "su2_en=1,su2_t=840,su2_p=0," + //
+                                "su3_en=0,su3_t=600,su3_p=70," + //
+                                "su4_en=1,su4_t=300,su4_p=50," + //
+                                "moc=0,tuc=0,thc=0,frc=0,sac=0,wec=0" //
+                )//
+
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource
+    public void jsonScheduleToParamStringTest(String scheduleJson, String expectedParamString) {
+        DemandControl info = DemandControl.parse("ret=OK,type=1,en_demand=1,mode=1");
+        Map<String, String> expectedParamMap = InfoParser.parse(expectedParamString);
+
+        info.setSchedule(scheduleJson);
+
+        Map<String, String> paramMap = info.getParamString();
+
+        expectedParamMap.entrySet().stream().forEach(expectedParam -> assertThat(paramMap,
+                hasEntry(is(expectedParam.getKey()), is(expectedParam.getValue()))));
+    }
+
+    private @Nullable Map<String, List<ScheduleEntry>> parseJson(String json) {
+        return new Gson().fromJson(json, new TypeToken<Map<String, List<ScheduleEntry>>>() {
+        }.getType());
+    }
+
+    public static Stream<Arguments> scheduledMaxPowerTest() {
+        return Stream.of( //
+                // empty schedule
+                Arguments.of("moc=0,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0", MONDAY, "12:00", 100),
+                // within the schedule of the day
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=1,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        MONDAY, "10:00", 60),
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=1,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        MONDAY, "10:05", 60),
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=1,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        MONDAY, "12:00", 70),
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=1,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        MONDAY, "15:00", 80),
+                // it should ignore disabled schedules
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=0,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        MONDAY, "15:00", 70),
+                // earlier than first schedule of the day, must look back and find the last schedule
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=1,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=1,fr1_en=1,fr1_t=10,fr1_p=77,sac=0,suc=0",
+                        MONDAY, "08:00", 77),
+                // test for boundary conditions (last item on the list, ie. sunday)
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=1,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=1,su1_en=1,su1_t=10,su1_p=77",
+                        MONDAY, "08:00", 77),
+                // earlier than first schedule of the day, no other days have schedules,
+                // so wrap around and pick the last schedule of the same day
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=1,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        MONDAY, "08:00", 80),
+                // but also ignore disabled schedules
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=0,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        MONDAY, "08:00", 70),
+                // empty schedule for the day, so look back until we find the last schedule
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=1,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        WEDNESDAY, "15:00", 80),
+                // it should also ignore disabled schedules in the previous days
+                Arguments.of(
+                        "moc=3,mo1_en=1,mo1_t=720,mo1_p=70,mo2_en=0,mo2_t=840,mo2_p=80,mo3_en=1,mo3_t=600,mo3_p=60,tuc=0,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        WEDNESDAY, "15:00", 70),
+                // it should wrap around and start search from the end of the week
+                Arguments.of(
+                        "moc=0,tuc=3,tu1_en=1,tu1_t=720,tu1_p=70,tu2_en=1,tu2_t=840,tu2_p=80,tu3_en=1,tu3_t=600,tu3_p=60,wec=0,thc=0,frc=0,sac=0,suc=0",
+                        MONDAY, "15:00", 80)
+        //
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource
+    public void scheduledMaxPowerTest(String input, DayOfWeek dow, String time, int expectedMaxPower) {
+        DemandControl info = DemandControl.parse(input);
+
+        LocalDateTime dateTime = LocalDateTime.now().with(java.time.temporal.TemporalAdjusters.next(dow))
+                .with(java.time.LocalTime.parse(time));
+
+        assertEquals(expectedMaxPower, info.getScheduledMaxPower(dateTime));
+    }
+}