]> git.basschouten.com Git - openhab-addons.git/blob
4d3eeaa758db8b31ad6b91a631cb5320c9c50f38
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.growatt.internal.cloud;
14
15 import java.lang.reflect.Type;
16 import java.net.HttpCookie;
17 import java.security.MessageDigest;
18 import java.security.NoSuchAlgorithmException;
19 import java.time.DateTimeException;
20 import java.time.Duration;
21 import java.time.LocalTime;
22 import java.util.ArrayList;
23 import java.util.LinkedHashMap;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.Objects;
27 import java.util.Optional;
28 import java.util.concurrent.ConcurrentHashMap;
29 import java.util.concurrent.ExecutionException;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.TimeoutException;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.client.api.ContentResponse;
37 import org.eclipse.jetty.client.api.Request;
38 import org.eclipse.jetty.client.util.FormContentProvider;
39 import org.eclipse.jetty.http.HttpMethod;
40 import org.eclipse.jetty.http.HttpStatus;
41 import org.eclipse.jetty.util.Fields;
42 import org.openhab.binding.growatt.internal.GrowattBindingConstants;
43 import org.openhab.binding.growatt.internal.config.GrowattBridgeConfiguration;
44 import org.openhab.binding.growatt.internal.dto.GrowattDevice;
45 import org.openhab.binding.growatt.internal.dto.GrowattPlant;
46 import org.openhab.binding.growatt.internal.dto.GrowattPlantList;
47 import org.openhab.binding.growatt.internal.dto.GrowattUser;
48 import org.openhab.core.io.net.http.HttpClientFactory;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
51
52 import com.google.gson.Gson;
53 import com.google.gson.JsonArray;
54 import com.google.gson.JsonElement;
55 import com.google.gson.JsonObject;
56 import com.google.gson.JsonParser;
57 import com.google.gson.JsonPrimitive;
58 import com.google.gson.JsonSyntaxException;
59 import com.google.gson.reflect.TypeToken;
60
61 /**
62  * The {@link GrowattCloud} class allows the binding to access the inverter state and settings via HTTP calls to the
63  * remote Growatt cloud API server (instead of receiving the data from the local Grott proxy server).
64  * <p>
65  * This class is necessary since the Grott proxy server does not (yet) support easy access to some inverter register
66  * settings, such as the settings for the battery charging and discharging programs.
67  *
68  * @author Andrew Fiddian-Green - Initial contribution
69  */
70 @NonNullByDefault
71 public class GrowattCloud implements AutoCloseable {
72
73     // JSON field names for the battery charging program
74     public static final String CHARGE_PROGRAM_POWER = "chargePowerCommand";
75     public static final String CHARGE_PROGRAM_TARGET_SOC = "wchargeSOCLowLimit2";
76     public static final String CHARGE_PROGRAM_ALLOW_AC_CHARGING = "acChargeEnable";
77     public static final String CHARGE_PROGRAM_START_TIME = "forcedChargeTimeStart1";
78     public static final String CHARGE_PROGRAM_STOP_TIME = "forcedChargeTimeStop1";
79     public static final String CHARGE_PROGRAM_ENABLE = "forcedChargeStopSwitch1";
80
81     // JSON field names for the battery discharging program
82     public static final String DISCHARGE_PROGRAM_POWER = "disChargePowerCommand";
83     public static final String DISCHARGE_PROGRAM_TARGET_SOC = "wdisChargeSOCLowLimit2";
84     public static final String DISCHARGE_PROGRAM_START_TIME = "forcedDischargeTimeStart1";
85     public static final String DISCHARGE_PROGRAM_STOP_TIME = "forcedDischargeTimeStop1";
86     public static final String DISCHARGE_PROGRAM_ENABLE = "forcedDischargeStopSwitch1";
87
88     // API server URL
89     private static final String SERVER_URL = "https://server-api.growatt.com/";
90
91     // API end points
92     private static final String LOGIN_API_ENDPOINT = "newTwoLoginAPI.do";
93     private static final String PLANT_LIST_API_ENDPOINT = "PlantListAPI.do";
94     private static final String PLANT_INFO_API_ENDPOINT = "newTwoPlantAPI.do";
95     private static final String NEW_TCP_SET_API_ENDPOINT = "newTcpsetAPI.do";
96
97     private static final String FMT_NEW_DEVICE_TYPE_API_DO = "new%sApi.do";
98
99     // command operations
100     private static final String OP_GET_ALL_DEVICE_LIST = "getAllDeviceList";
101
102     // enum of device types
103     private static enum DeviceType {
104         MIX,
105         MAX,
106         MIN,
107         SPA,
108         SPH,
109         TLX
110     }
111
112     /*
113      * Map of device types vs. field parameters for GET requests to FMT_NEW_DEVICE_TYPE_API_DO end-points.
114      * Note: some values are guesses which have not yet been confirmed by users
115      */
116     private static final Map<DeviceType, String> SUPPORTED_TYPES_GET_PARAM = Map.of(
117     // @formatter:off
118             DeviceType.MIX, "getMixSetParams",
119             DeviceType.MAX, "getMaxSetData",
120             DeviceType.MIN, "getMinSetData",
121             DeviceType.SPA, "getSpaSetData",
122             DeviceType.SPH, "getSphSetData",
123             DeviceType.TLX, "getTlxSetData"
124     // @formatter:on
125     );
126
127     /*
128      * Map of device types vs. field parameters for POST commands to NEW_TCP_SET_API_ENDPOINT.
129      * Note: some values are guesses which have not yet been confirmed by users
130      */
131     private static final Map<DeviceType, String> SUPPORTED_TYPE_POST_PARAM = Map.of(
132     // @formatter:off
133             DeviceType.MIX, "mixSetApiNew", // was "mixSetApi"
134             DeviceType.MAX, "maxSetApi",
135             DeviceType.MIN, "minSetApi",
136             DeviceType.SPA, "spaSetApi",
137             DeviceType.SPH, "sphSet",
138             DeviceType.TLX, "tlxSet"
139     // @formatter:on
140     );
141
142     // enum to select charge resp. discharge program
143     private static enum ProgramType {
144         CHARGE,
145         DISCHARGE
146     }
147
148     // enum of program modes
149     public static enum ProgramMode {
150         LOAD_FIRST,
151         BATTERY_FIRST,
152         GRID_FIRST
153     }
154
155     // @formatter:off
156     private static final Type DEVICE_LIST_TYPE = new TypeToken<List<GrowattDevice>>() {}.getType();
157     // @formatter:on
158
159     // HTTP headers (user agent is spoofed to mimic the Growatt Android Shine app)
160     private static final String USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 12; https://www.openhab.org)";
161     private static final String FORM_CONTENT = "application/x-www-form-urlencoded";
162
163     private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(10);
164
165     private final Logger logger = LoggerFactory.getLogger(GrowattCloud.class);
166     private final HttpClient httpClient;
167     private final GrowattBridgeConfiguration configuration;
168     private final Gson gson = new Gson();
169     private final List<String> plantIds = new ArrayList<>();
170     private final Map<String, DeviceType> deviceIdTypeMap = new ConcurrentHashMap<>();
171
172     private String userId = "";
173
174     /**
175      * Constructor.
176      *
177      * @param configuration the bridge configuration parameters.
178      * @param httpClientFactory the OH core {@link HttpClientFactory} instance.
179      * @throws Exception if anything goes wrong.
180      */
181     public GrowattCloud(GrowattBridgeConfiguration configuration, HttpClientFactory httpClientFactory)
182             throws Exception {
183         this.configuration = configuration;
184         this.httpClient = httpClientFactory.createHttpClient(GrowattBindingConstants.BINDING_ID);
185         this.httpClient.start();
186     }
187
188     @Override
189     public void close() throws Exception {
190         httpClient.stop();
191     }
192
193     /**
194      * Create a hash of the given password using normal MD5, except add 'c' if a byte of the digest is less than 10
195      *
196      * @param password the plain text password
197      * @return the hash of the password
198      * @throws GrowattApiException if MD5 algorithm is not supported
199      */
200     private static String createHash(String password) throws GrowattApiException {
201         MessageDigest md;
202         try {
203             md = MessageDigest.getInstance("MD5");
204         } catch (NoSuchAlgorithmException e) {
205             throw new GrowattApiException("Hash algorithm error", e);
206         }
207         byte[] bytes = md.digest(password.getBytes());
208         StringBuilder result = new StringBuilder();
209         for (byte b : bytes) {
210             result.append(String.format("%02x", b));
211         }
212         for (int i = 0; i < result.length(); i += 2) {
213             if (result.charAt(i) == '0') {
214                 result.replace(i, i + 1, "c");
215             }
216         }
217         return result.toString();
218     }
219
220     /**
221      * Refresh the login cookies.
222      *
223      * @throws GrowattApiException if any error occurs.
224      */
225     private void refreshCookies() throws GrowattApiException {
226         List<HttpCookie> cookies = httpClient.getCookieStore().getCookies();
227         if (cookies.isEmpty() || cookies.stream().anyMatch(HttpCookie::hasExpired)) {
228             postLoginCredentials();
229         }
230     }
231
232     /**
233      * Login to the server (if necessary) and then execute an HTTP request using the given HTTP method, to the given end
234      * point, and with the given request URL parameters and/or request form fields. If the cookies are not valid first
235      * login to the server before making the actual HTTP request.
236      *
237      * @param method the HTTP method to use.
238      * @param endPoint the API end point.
239      * @param params the request URL parameters (may be null).
240      * @param fields the request form fields (may be null).
241      * @return a Map of JSON elements containing the server response.
242      * @throws GrowattApiException if any error occurs.
243      */
244     private Map<String, JsonElement> doHttpRequest(HttpMethod method, String endPoint,
245             @Nullable Map<String, String> params, @Nullable Fields fields) throws GrowattApiException {
246         refreshCookies();
247         return doHttpRequestInner(method, endPoint, params, fields);
248     }
249
250     /**
251      * Inner method to execute an HTTP request using the given HTTP method, to the given end point, and with the given
252      * request URL parameters and/or request form fields.
253      *
254      * @param method the HTTP method to use.
255      * @param endPoint the API end point.
256      * @param params the request URL parameters (may be null).
257      * @param fields the request form fields (may be null).
258      * @return a Map of JSON elements containing the server response.
259      * @throws GrowattApiException if any error occurs.
260      */
261     private Map<String, JsonElement> doHttpRequestInner(HttpMethod method, String endPoint,
262             @Nullable Map<String, String> params, @Nullable Fields fields) throws GrowattApiException {
263         //
264         Request request = httpClient.newRequest(SERVER_URL + endPoint).method(method).agent(USER_AGENT)
265                 .timeout(HTTP_TIMEOUT.getSeconds(), TimeUnit.SECONDS);
266
267         if (params != null) {
268             params.entrySet().forEach(p -> request.param(p.getKey(), p.getValue()));
269         }
270
271         if (fields != null) {
272             request.content(new FormContentProvider(fields), FORM_CONTENT);
273         }
274
275         if (logger.isTraceEnabled()) {
276             logger.trace("{} {}{} {} {}", method, request.getPath(), params == null ? "" : "?" + request.getQuery(),
277                     request.getVersion(), fields == null ? "" : "? " + FormContentProvider.convert(fields));
278         }
279
280         ContentResponse response;
281         try {
282             response = request.send();
283         } catch (InterruptedException | TimeoutException | ExecutionException e) {
284             throw new GrowattApiException("HTTP I/O Exception", e);
285         }
286
287         int status = response.getStatus();
288         String content = response.getContentAsString();
289
290         logger.trace("HTTP {} {} {}", status, HttpStatus.getMessage(status), content);
291
292         if (status != HttpStatus.OK_200) {
293             throw new GrowattApiException(String.format("HTTP %d %s", status, HttpStatus.getMessage(status)));
294         }
295
296         if (content == null || content.isBlank()) {
297             throw new GrowattApiException("Response is " + (content == null ? "null" : "blank"));
298         }
299
300         if (content.contains("<html>")) {
301             logger.warn("HTTP {} {} {}", status, HttpStatus.getMessage(status), content);
302             throw new GrowattApiException("Response is HTML");
303         }
304
305         try {
306             JsonElement jsonObject = JsonParser.parseString(content).getAsJsonObject();
307             if (jsonObject instanceof JsonObject jsonElement) {
308                 return jsonElement.asMap();
309             }
310             throw new GrowattApiException("Response JSON invalid");
311         } catch (JsonSyntaxException | IllegalStateException e) {
312             throw new GrowattApiException("Response JSON syntax exception", e);
313         }
314     }
315
316     /**
317      * Get the deviceType for the given deviceId. If the deviceIdTypeMap is empty then download it freshly.
318      *
319      * @param the deviceId to get.
320      * @return the deviceType.
321      * @throws GrowattApiException if any error occurs.
322      */
323     private DeviceType getDeviceTypeChecked(String deviceId) throws GrowattApiException {
324         if (deviceIdTypeMap.isEmpty()) {
325             if (plantIds.isEmpty()) {
326                 refreshCookies();
327             }
328             for (String plantId : plantIds) {
329                 for (GrowattDevice device : getPlantInfo(plantId)) {
330                     try {
331                         deviceIdTypeMap.put(device.getId(), DeviceType.valueOf(device.getType().toUpperCase()));
332                     } catch (IllegalArgumentException e) {
333                         // just ignore unsupported device types
334                     }
335                 }
336             }
337             logger.debug("Downloaded deviceTypes:{}", deviceIdTypeMap);
338         }
339         if (deviceId.isBlank()) {
340             throw new GrowattApiException("Device id is blank");
341         }
342         DeviceType deviceType = deviceIdTypeMap.get(deviceId);
343         if (deviceType != null) {
344             return deviceType;
345         }
346         throw new GrowattApiException("Unsupported device:" + deviceId);
347     }
348
349     /**
350      * Get the inverter device settings.
351      *
352      * @param the deviceId to get.
353      * @return a Map of JSON elements containing the server response.
354      * @throws GrowattApiException if any error occurs.
355      */
356     public Map<String, JsonElement> getDeviceSettings(String deviceId) throws GrowattApiException {
357         DeviceType deviceType = getDeviceTypeChecked(deviceId);
358         String dt = deviceType.name().toLowerCase();
359
360         String endPoint = String.format(FMT_NEW_DEVICE_TYPE_API_DO, dt.substring(0, 1).toUpperCase() + dt.substring(1));
361
362         Map<String, String> params = new LinkedHashMap<>(); // keep params in order
363         params.put("op", Objects.requireNonNull(SUPPORTED_TYPES_GET_PARAM.get(deviceType)));
364         params.put("serialNum", deviceId);
365         params.put("kind", "0");
366
367         Map<String, JsonElement> result = doHttpRequest(HttpMethod.GET, endPoint, params, null);
368
369         JsonElement obj = result.get("obj");
370         if (obj instanceof JsonObject object) {
371             Map<String, JsonElement> map = object.asMap();
372             Optional<String> key = map.keySet().stream().filter(k -> k.toLowerCase().endsWith("bean")).findFirst();
373             if (key.isPresent()) {
374                 JsonElement beanJson = map.get(key.get());
375                 if (beanJson instanceof JsonObject bean) {
376                     return bean.asMap();
377                 }
378             }
379         }
380         throw new GrowattApiException("Invalid JSON response");
381     }
382
383     /**
384      * Get the plant information.
385      *
386      * @param the plantId to get.
387      * @return a list of {@link GrowattDevice} containing the server response.
388      * @throws GrowattApiException if any error occurs.
389      */
390     public List<GrowattDevice> getPlantInfo(String plantId) throws GrowattApiException {
391         Map<String, String> params = new LinkedHashMap<>(); // keep params in order
392         params.put("op", OP_GET_ALL_DEVICE_LIST);
393         params.put("plantId", plantId);
394         params.put("pageNum", "1");
395         params.put("pageSize", "1");
396
397         Map<String, JsonElement> result = doHttpRequest(HttpMethod.GET, PLANT_INFO_API_ENDPOINT, params, null);
398
399         JsonElement deviceList = result.get("deviceList");
400         if (deviceList instanceof JsonArray deviceArray) {
401             try {
402                 List<GrowattDevice> devices = gson.fromJson(deviceArray, DEVICE_LIST_TYPE);
403                 if (devices != null) {
404                     return devices;
405                 }
406             } catch (JsonSyntaxException e) {
407                 // fall through
408             }
409         }
410         throw new GrowattApiException("Invalid JSON response");
411     }
412
413     /**
414      * Get the plant list.
415      *
416      * @param the userId to get from.
417      * @return a {@link GrowattPlantList} containing the server response.
418      * @throws GrowattApiException if any error occurs.
419      */
420     public GrowattPlantList getPlantList(String userId) throws GrowattApiException {
421         Map<String, String> params = new LinkedHashMap<>(); // keep params in order
422         params.put("userId", userId);
423
424         Map<String, JsonElement> result = doHttpRequest(HttpMethod.GET, PLANT_LIST_API_ENDPOINT, params, null);
425
426         JsonElement back = result.get("back");
427         if (back instanceof JsonObject backObject) {
428             try {
429                 GrowattPlantList plantList = gson.fromJson(backObject, GrowattPlantList.class);
430                 if (plantList != null && plantList.getSuccess()) {
431                     return plantList;
432                 }
433             } catch (JsonSyntaxException e) {
434                 // fall through
435             }
436         }
437         throw new GrowattApiException("Invalid JSON response");
438     }
439
440     /**
441      * Attempt to login to the remote server by posting the given user credentials.
442      *
443      * @throws GrowattApiException if any error occurs.
444      */
445     private void postLoginCredentials() throws GrowattApiException {
446         String userName = configuration.userName;
447         if (userName == null || userName.isBlank()) {
448             throw new GrowattApiException("User name missing");
449         }
450         String password = configuration.password;
451         if (password == null || password.isBlank()) {
452             throw new GrowattApiException("Password missing");
453         }
454
455         Fields fields = new Fields();
456         fields.put("userName", userName);
457         fields.put("password", createHash(password));
458
459         Map<String, JsonElement> result = doHttpRequestInner(HttpMethod.POST, LOGIN_API_ENDPOINT, null, fields);
460
461         JsonElement back = result.get("back");
462         if (back instanceof JsonObject backObject) {
463             try {
464                 GrowattPlantList plantList = gson.fromJson(backObject, GrowattPlantList.class);
465                 if (plantList != null && plantList.getSuccess()) {
466                     GrowattUser user = plantList.getUserId();
467                     userId = user != null ? user.getId() : userId;
468                     plantIds.clear();
469                     plantIds.addAll(plantList.getPlants().stream().map(GrowattPlant::getId).toList());
470                     logger.debug("Logged in userId:{}, plantIds:{}", userId, plantIds);
471                     return;
472                 }
473             } catch (JsonSyntaxException e) {
474                 // fall through
475             }
476         }
477         throw new GrowattApiException("Login failed");
478     }
479
480     /**
481      * Post a command to setup the inverter battery charging program.
482      *
483      * @param the deviceId to set up
484      * @param programModeInt index of the type of program Load First (0) / Battery First (1) / Grid First (2)
485      * @param powerLevel the rate of charging / discharging
486      * @param stopSOC the SOC at which to stop charging / discharging
487      * @param enableAcCharging allow charging from AC power
488      * @param startTime the start time of the charging / discharging program
489      * @param stopTime the stop time of the charging / discharging program
490      * @param enableProgram charging / discharging program shall be enabled
491      *
492      * @throws GrowattApiException if any error occurs
493      */
494     public void setupBatteryProgram(String deviceId, int programModeInt, @Nullable Integer powerLevel,
495             @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging, @Nullable String startTime,
496             @Nullable String stopTime, @Nullable Boolean enableProgram) throws GrowattApiException {
497         //
498         if (deviceId.isBlank()) {
499             throw new GrowattApiException("Device id is blank");
500         }
501
502         ProgramMode programMode;
503         try {
504             programMode = ProgramMode.values()[programModeInt];
505         } catch (IndexOutOfBoundsException e) {
506             throw new GrowattApiException("Program mode is out of range (0..2)");
507         }
508
509         DeviceType deviceType = getDeviceTypeChecked(deviceId);
510         switch (deviceType) {
511
512             case MIX:
513             case SPA:
514                 setTimeProgram(deviceId, deviceType,
515                         programMode == ProgramMode.BATTERY_FIRST ? ProgramType.CHARGE : ProgramType.DISCHARGE,
516                         powerLevel, stopSOC, enableAcCharging, startTime, stopTime, enableProgram);
517                 return;
518
519             case TLX:
520                 if (enableAcCharging != null) {
521                     setEnableAcCharging(deviceId, deviceType, enableAcCharging);
522                 }
523                 if (powerLevel != null) {
524                     setPowerLevel(deviceId, deviceType, programMode, powerLevel);
525                 }
526                 if (stopSOC != null) {
527                     setStopSOC(deviceId, deviceType, programMode, stopSOC);
528                 }
529                 if (startTime != null || stopTime != null || enableProgram != null) {
530                     setTimeSegment(deviceId, deviceType, programMode, startTime, stopTime, enableProgram);
531                 }
532                 return;
533
534             default:
535         }
536         throw new GrowattApiException("Unsupported device type:" + deviceType.name());
537     }
538
539     /**
540      * Look for an entry in the given Map, and return its value as a boolean.
541      *
542      * @param map the source map.
543      * @param key the key to search for in the map.
544      * @return the boolean value.
545      * @throws GrowattApiException if any error occurs.
546      */
547     public static boolean mapGetBoolean(Map<String, JsonElement> map, String key) throws GrowattApiException {
548         JsonElement element = map.get(key);
549         if (element instanceof JsonPrimitive primitive) {
550             if (primitive.isBoolean()) {
551                 return primitive.getAsBoolean();
552             } else if (primitive.isNumber() || primitive.isString()) {
553                 try {
554                     switch (primitive.getAsInt()) {
555                         case 0:
556                             return false;
557                         case 1:
558                             return true;
559                     }
560                 } catch (NumberFormatException e) {
561                     throw new GrowattApiException("Boolean bad value", e);
562                 }
563             }
564         }
565         throw new GrowattApiException("Boolean missing or bad value");
566     }
567
568     /**
569      * Look for an entry in the given Map, and return its value as an integer.
570      *
571      * @param map the source map.
572      * @param key the key to search for in the map.
573      * @return the integer value.
574      * @throws GrowattApiException if any error occurs.
575      */
576     public static int mapGetInteger(Map<String, JsonElement> map, String key) throws GrowattApiException {
577         JsonElement element = map.get(key);
578         if (element instanceof JsonPrimitive primitive) {
579             try {
580                 return primitive.getAsInt();
581             } catch (NumberFormatException e) {
582                 throw new GrowattApiException("Integer bad value", e);
583             }
584         }
585         throw new GrowattApiException("Integer missing or bad value");
586     }
587
588     /**
589      * Look for an entry in the given Map, and return its value as a LocalTime.
590      *
591      * @param source the source map.
592      * @param key the key to search for in the map.
593      * @return the LocalTime.
594      * @throws GrowattApiException if any error occurs.
595      */
596     public static LocalTime mapGetLocalTime(Map<String, JsonElement> source, String key) throws GrowattApiException {
597         JsonElement element = source.get(key);
598         if ((element instanceof JsonPrimitive primitive) && primitive.isString()) {
599             try {
600                 return localTimeOf(primitive.getAsString());
601             } catch (DateTimeException e) {
602                 throw new GrowattApiException("LocalTime bad value", e);
603             }
604         }
605         throw new GrowattApiException("LocalTime missing or bad value");
606     }
607
608     /**
609      * Parse a time formatted string into a LocalTime entity.
610      * <p>
611      * Note: unlike the standard LocalTime.parse() method, this method accepts hour and minute fields from the Growatt
612      * server that are without leading zeros e.g. "1:1" and it accepts the conventional "01:01" format too.
613      *
614      * @param localTime a time formatted string e.g. "12:34"
615      * @return a corresponding LocalTime entity.
616      * @throws DateTimeException if any error occurs.
617      */
618     public static LocalTime localTimeOf(String localTime) throws DateTimeException {
619         String splitParts[] = localTime.split(":");
620         if (splitParts.length < 2) {
621             throw new DateTimeException("LocalTime bad value");
622         }
623         try {
624             return LocalTime.of(Integer.valueOf(splitParts[0]), Integer.valueOf(splitParts[1]));
625         } catch (NumberFormatException | DateTimeException e) {
626             throw new DateTimeException("LocalTime bad value", e);
627         }
628     }
629
630     /**
631      * Post a command to set up the inverter battery charging / discharging program.
632      *
633      * @param the deviceId to set up
634      * @param the deviceType to set up
635      * @param programType selects whether the program is for charge or discharge
636      * @param powerLevel the rate of charging / discharging 1%..100%
637      * @param stopSOC the SOC at which to stop the program 5%..100%
638      * @param enableAcCharging allow charging from AC power (only applies to hybrid/mix inverters)
639      * @param startTime the start time of the program
640      * @param stopTime the stop time of the program
641      * @param enableProgram the program shall be enabled
642      *
643      * @throws GrowattApiException if any error occurs
644      */
645     private void setTimeProgram(String deviceId, DeviceType deviceType, ProgramType programType,
646             @Nullable Integer powerLevel, @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging,
647             @Nullable String startTime, @Nullable String stopTime, @Nullable Boolean enableProgram)
648             throws GrowattApiException {
649         //
650         if (powerLevel == null || powerLevel < 1 || powerLevel > 100) {
651             throw new GrowattApiException("Power level parameter is null or out of range (1%..100%)");
652         }
653         if (stopSOC == null || stopSOC < 5 || stopSOC > 100) {
654             throw new GrowattApiException("Target SOC parameter is null out of range (5%..100%)");
655         }
656         if (startTime == null) {
657             throw new GrowattApiException("Start time parameter is null");
658         }
659         if (stopTime == null) {
660             throw new GrowattApiException("Stop time parameter is null");
661         }
662         if (enableProgram == null) {
663             throw new GrowattApiException("Program enable parameter is null");
664         }
665         boolean isMixChargeCommand = deviceType == DeviceType.MIX && programType == ProgramType.CHARGE;
666         if (isMixChargeCommand && enableAcCharging == null) {
667             throw new GrowattApiException("Allow ac charging parameter is null");
668         }
669         LocalTime localStartTime;
670         try {
671             localStartTime = GrowattCloud.localTimeOf(startTime);
672         } catch (DateTimeException e) {
673             throw new GrowattApiException("Start time is invalid");
674         }
675         LocalTime localStopTime;
676         try {
677             localStopTime = GrowattCloud.localTimeOf(stopTime);
678         } catch (DateTimeException e) {
679             throw new GrowattApiException("Stop time is invalid");
680         }
681
682         Fields fields = new Fields();
683
684         fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
685         fields.put("serialNum", deviceId);
686         fields.put("type", String.format("%s_ac_%s_time_period", deviceType.name().toLowerCase(),
687                 programType.name().toLowerCase()));
688
689         int paramId = 1;
690
691         paramId = addParam(fields, paramId, String.format("%d", powerLevel));
692         paramId = addParam(fields, paramId, String.format("%d", stopSOC));
693         if (isMixChargeCommand) {
694             paramId = addParam(fields, paramId, enableAcCharging ? "1" : "0");
695         }
696         paramId = addParam(fields, paramId, String.format("%02d", localStartTime.getHour()));
697         paramId = addParam(fields, paramId, String.format("%02d", localStartTime.getMinute()));
698         paramId = addParam(fields, paramId, String.format("%02d", localStopTime.getHour()));
699         paramId = addParam(fields, paramId, String.format("%02d", localStopTime.getMinute()));
700         paramId = addParam(fields, paramId, enableProgram ? "1" : "0");
701         paramId = addParam(fields, paramId, "00");
702         paramId = addParam(fields, paramId, "00");
703         paramId = addParam(fields, paramId, "00");
704         paramId = addParam(fields, paramId, "00");
705         paramId = addParam(fields, paramId, "0");
706         paramId = addParam(fields, paramId, "00");
707         paramId = addParam(fields, paramId, "00");
708         paramId = addParam(fields, paramId, "00");
709         paramId = addParam(fields, paramId, "00");
710         paramId = addParam(fields, paramId, "0");
711
712         postSetCommandForm(fields);
713     }
714
715     /**
716      * Add a new entry in the given {@link Fields} map in the form "paramN" = paramValue where N is the parameter index.
717      *
718      * @param fields the map to be added to.
719      * @param parameterIndex the parameter index.
720      * @param parameterValue the parameter value.
721      *
722      * @return the next parameter index.
723      */
724     private int addParam(Fields fields, int parameterIndex, String parameterValue) {
725         fields.put(String.format("param%d", parameterIndex), parameterValue);
726         return parameterIndex + 1;
727     }
728
729     /**
730      * Inner method to execute a POST setup command using the given form fields.
731      *
732      * @param fields the form fields to be posted.
733      *
734      * @throws GrowattApiException if any error occurs
735      */
736     private void postSetCommandForm(Fields fields) throws GrowattApiException {
737         Map<String, JsonElement> result = doHttpRequest(HttpMethod.POST, NEW_TCP_SET_API_ENDPOINT, null, fields);
738         JsonElement success = result.get("success");
739         if (success instanceof JsonPrimitive sucessPrimitive) {
740             if (sucessPrimitive.getAsBoolean()) {
741                 return;
742             }
743         }
744         throw new GrowattApiException("Command failed");
745     }
746
747     /**
748      * Post a command to enable / disable ac charging.
749      *
750      * @param the deviceId to set up
751      * @param the deviceType to set up
752      * @param enableAcCharging enable or disable the function
753      *
754      * @throws GrowattApiException if any error occurs
755      */
756     private void setEnableAcCharging(String deviceId, DeviceType deviceType, boolean enableAcCharging)
757             throws GrowattApiException {
758         //
759         Fields fields = new Fields();
760
761         fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
762         fields.put("serialNum", deviceId);
763         fields.put("type", "ac_charge");
764         fields.put("param1", enableAcCharging ? "1" : "0");
765
766         postSetCommandForm(fields);
767     }
768
769     /**
770      * Post a command to set up a program charge / discharge power level.
771      *
772      * @param the deviceId to set up
773      * @param the deviceType to set up
774      * @param programMode the program mode that the setting shall apply to
775      * @param powerLevel the rate of charging / discharging 1%..100%
776      *
777      * @throws GrowattApiException if any error occurs
778      */
779     private void setPowerLevel(String deviceId, DeviceType deviceType, ProgramMode programMode, int powerLevel)
780             throws GrowattApiException {
781         //
782         if (powerLevel < 1 || powerLevel > 100) {
783             throw new GrowattApiException("Power level out of range (1%..100%)");
784         }
785
786         String typeParam;
787         switch (programMode) {
788             case BATTERY_FIRST:
789                 typeParam = "charge_power";
790                 break;
791             case GRID_FIRST:
792             case LOAD_FIRST:
793                 typeParam = "discharge_power";
794                 break;
795             default:
796                 throw new GrowattApiException("Unexpected exception");
797         }
798
799         Fields fields = new Fields();
800
801         fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
802         fields.put("serialNum", deviceId);
803         fields.put("type", typeParam);
804         fields.put("param1", String.format("%d", powerLevel));
805
806         postSetCommandForm(fields);
807     }
808
809     /**
810      * Post a command to set up a program target (stop) SOC level.
811      *
812      * @param the deviceId to set up
813      * @param the deviceType to set up
814      * @param programMode the program mode that the setting shall apply to
815      * @param stopSOC the SOC at which to stop the program 11%..100%
816      *
817      * @throws GrowattApiException if any error occurs
818      */
819     private void setStopSOC(String deviceId, DeviceType deviceType, ProgramMode programMode, int stopSOC)
820             throws GrowattApiException {
821         //
822         if (stopSOC < 11 || stopSOC > 100) {
823             throw new GrowattApiException("Target SOC out of range (11%..100%)");
824         }
825
826         String typeParam;
827         switch (programMode) {
828             case BATTERY_FIRST:
829                 typeParam = "charge_stop_soc";
830                 break;
831             case GRID_FIRST:
832                 typeParam = "on_grid_discharge_stop_soc";
833                 break;
834             case LOAD_FIRST:
835                 typeParam = "discharge_stop_soc";
836                 break;
837             default:
838                 throw new GrowattApiException("Unexpected exception");
839         }
840
841         Fields fields = new Fields();
842
843         fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
844         fields.put("serialNum", deviceId);
845         fields.put("type", typeParam);
846         fields.put("param1", String.format("%d", stopSOC));
847
848         postSetCommandForm(fields);
849     }
850
851     /**
852      * Post a command to set up a time segment program.
853      * Note: uses separate dedicated time segments for Load First, Battery First, Grid First modes.
854      *
855      * @param the deviceId to set up
856      * @param the deviceType to set up
857      * @param programMode the program mode for the time segment
858      * @param startTime the start time of the program
859      * @param stopTime the stop time of the program
860      * @param enableProgram the program shall be enabled
861      *
862      * @throws GrowattApiException if any error occurs
863      */
864     private void setTimeSegment(String deviceId, DeviceType deviceType, ProgramMode programMode,
865             @Nullable String startTime, @Nullable String stopTime, @Nullable Boolean enableProgram)
866             throws GrowattApiException {
867         //
868         if (startTime == null) {
869             throw new GrowattApiException("Start time parameter is null");
870         }
871         if (stopTime == null) {
872             throw new GrowattApiException("Stop time parameter is null");
873         }
874         if (enableProgram == null) {
875             throw new GrowattApiException("Program enable parameter is null");
876         }
877         LocalTime localStartTime;
878         try {
879             localStartTime = GrowattCloud.localTimeOf(startTime);
880         } catch (DateTimeException e) {
881             throw new GrowattApiException("Start time is invalid");
882         }
883         LocalTime localStopTime;
884         try {
885             localStopTime = GrowattCloud.localTimeOf(stopTime);
886         } catch (DateTimeException e) {
887             throw new GrowattApiException("Stop time is invalid");
888         }
889
890         Fields fields = new Fields();
891
892         fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
893         fields.put("serialNum", deviceId);
894         fields.put("type", String.format("time_segment%d", programMode.ordinal() + 1));
895         fields.put("param1", String.format("%d", programMode.ordinal()));
896         fields.put("param2", String.format("%02d", localStartTime.getHour()));
897         fields.put("param3", String.format("%02d", localStartTime.getMinute()));
898         fields.put("param4", String.format("%02d", localStopTime.getHour()));
899         fields.put("param5", String.format("%02d", localStopTime.getMinute()));
900         fields.put("param6", enableProgram ? "1" : "0");
901
902         postSetCommandForm(fields);
903     }
904 }