]> git.basschouten.com Git - openhab-addons.git/blob
9ce18af1f8f6abdc03d46dd328cd18fc69579e5b
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.millheat.internal.handler;
14
15 import java.nio.charset.StandardCharsets;
16 import java.security.MessageDigest;
17 import java.security.NoSuchAlgorithmException;
18 import java.security.SecureRandom;
19 import java.util.List;
20 import java.util.Optional;
21 import java.util.Random;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.api.ContentResponse;
31 import org.eclipse.jetty.client.api.Request;
32 import org.eclipse.jetty.client.util.BytesContentProvider;
33 import org.eclipse.jetty.http.HttpMethod;
34 import org.eclipse.jetty.http.HttpStatus;
35 import org.openhab.binding.millheat.internal.MillheatCommunicationException;
36 import org.openhab.binding.millheat.internal.client.BooleanSerializer;
37 import org.openhab.binding.millheat.internal.client.RequestLogger;
38 import org.openhab.binding.millheat.internal.config.MillheatAccountConfiguration;
39 import org.openhab.binding.millheat.internal.dto.AbstractRequest;
40 import org.openhab.binding.millheat.internal.dto.AbstractResponse;
41 import org.openhab.binding.millheat.internal.dto.DeviceDTO;
42 import org.openhab.binding.millheat.internal.dto.GetHomesRequest;
43 import org.openhab.binding.millheat.internal.dto.GetHomesResponse;
44 import org.openhab.binding.millheat.internal.dto.GetIndependentDevicesByHomeRequest;
45 import org.openhab.binding.millheat.internal.dto.GetIndependentDevicesByHomeResponse;
46 import org.openhab.binding.millheat.internal.dto.HomeDTO;
47 import org.openhab.binding.millheat.internal.dto.LoginRequest;
48 import org.openhab.binding.millheat.internal.dto.LoginResponse;
49 import org.openhab.binding.millheat.internal.dto.RoomDTO;
50 import org.openhab.binding.millheat.internal.dto.SelectDeviceByRoomRequest;
51 import org.openhab.binding.millheat.internal.dto.SelectDeviceByRoomResponse;
52 import org.openhab.binding.millheat.internal.dto.SelectRoomByHomeRequest;
53 import org.openhab.binding.millheat.internal.dto.SelectRoomByHomeResponse;
54 import org.openhab.binding.millheat.internal.dto.SetDeviceTempRequest;
55 import org.openhab.binding.millheat.internal.dto.SetHolidayParameterRequest;
56 import org.openhab.binding.millheat.internal.dto.SetHolidayParameterResponse;
57 import org.openhab.binding.millheat.internal.dto.SetRoomTempRequest;
58 import org.openhab.binding.millheat.internal.dto.SetRoomTempResponse;
59 import org.openhab.binding.millheat.internal.model.Heater;
60 import org.openhab.binding.millheat.internal.model.Home;
61 import org.openhab.binding.millheat.internal.model.MillheatModel;
62 import org.openhab.binding.millheat.internal.model.ModeType;
63 import org.openhab.binding.millheat.internal.model.Room;
64 import org.openhab.core.library.types.DateTimeType;
65 import org.openhab.core.library.types.OnOffType;
66 import org.openhab.core.library.types.QuantityType;
67 import org.openhab.core.thing.Bridge;
68 import org.openhab.core.thing.ChannelUID;
69 import org.openhab.core.thing.Thing;
70 import org.openhab.core.thing.ThingStatus;
71 import org.openhab.core.thing.ThingStatusDetail;
72 import org.openhab.core.thing.binding.BaseBridgeHandler;
73 import org.openhab.core.thing.binding.ThingHandler;
74 import org.openhab.core.types.Command;
75 import org.openhab.core.util.HexUtils;
76 import org.osgi.framework.BundleContext;
77 import org.slf4j.Logger;
78 import org.slf4j.LoggerFactory;
79
80 import com.google.gson.Gson;
81 import com.google.gson.GsonBuilder;
82
83 /**
84  * The {@link MillheatAccountHandler} is responsible for handling commands, which are
85  * sent to one of the channels.
86  *
87  * @author Arne Seime - Initial contribution
88  */
89 @NonNullByDefault
90 public class MillheatAccountHandler extends BaseBridgeHandler {
91     private static final String SHA_1_ALGORITHM = "SHA-1";
92     private static final int MIN_TIME_BETWEEEN_MODEL_UPDATES_MS = 30_000;
93     private static final int NUM_NONCE_CHARS = 16;
94     private static final String CONTENT_TYPE = "application/x-zc-object";
95     private static final String ALLOWED_NONCE_CHARACTERS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
96     private static final int ALLOWED_NONCE_CHARACTERS_LENGTH = ALLOWED_NONCE_CHARACTERS.length();
97     private static final String REQUEST_TIMEOUT = "300";
98     public static String authEndpoint = "https://eurouter.ablecloud.cn:9005/zc-account/v1/";
99     public static String serviceEndpoint = "https://eurouter.ablecloud.cn:9005/millService/v1/";
100     private final Logger logger = LoggerFactory.getLogger(MillheatAccountHandler.class);
101     private @Nullable String userId;
102     private @Nullable String token;
103     private final HttpClient httpClient;
104     private final RequestLogger requestLogger;
105     private final Gson gson;
106     private MillheatModel model = new MillheatModel(0);
107     private @Nullable ScheduledFuture<?> statusFuture;
108     private @NonNullByDefault({}) MillheatAccountConfiguration config;
109
110     private static String getRandomString(final int sizeOfRandomString) {
111         final Random random = new SecureRandom();
112         final StringBuilder sb = new StringBuilder(sizeOfRandomString);
113         for (int i = 0; i < sizeOfRandomString; ++i) {
114             sb.append(ALLOWED_NONCE_CHARACTERS.charAt(random.nextInt(ALLOWED_NONCE_CHARACTERS_LENGTH)));
115         }
116         return sb.toString();
117     }
118
119     public MillheatAccountHandler(final Bridge bridge, final HttpClient httpClient, final BundleContext context) {
120         super(bridge);
121         this.httpClient = httpClient;
122         final BooleanSerializer serializer = new BooleanSerializer();
123
124         gson = new GsonBuilder().setPrettyPrinting().setDateFormat("yyyy-MM-dd HH:mm:ss")
125                 .registerTypeAdapter(Boolean.class, serializer).registerTypeAdapter(boolean.class, serializer)
126                 .setLenient().create();
127         requestLogger = new RequestLogger(bridge.getUID().getId(), gson);
128     }
129
130     private boolean allowModelUpdate() {
131         final long timeSinceLastUpdate = System.currentTimeMillis() - model.getLastUpdated();
132         return timeSinceLastUpdate > MIN_TIME_BETWEEEN_MODEL_UPDATES_MS;
133     }
134
135     public MillheatModel getModel() {
136         return model;
137     }
138
139     @Override
140     public void handleCommand(final ChannelUID channelUID, final Command command) {
141         logger.debug("Bridge does not support any commands, but received command {} for channelUID {}", command,
142                 channelUID);
143     }
144
145     public boolean doLogin() {
146         try {
147             final LoginResponse rsp = sendLoginRequest(new LoginRequest(config.username, config.password),
148                     LoginResponse.class);
149             final int errorCode = rsp.errorCode;
150             if (errorCode != 0) {
151                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
152                         String.format("Error login in: code=%s, type=%s, message=%s", errorCode, rsp.errorName,
153                                 rsp.errorDescription));
154             } else {
155                 // No error provided on login, proceed to find token and userid
156                 String localToken = rsp.token.trim();
157                 userId = rsp.userId == null ? null : rsp.userId.toString();
158                 if (localToken == null || localToken.isEmpty()) {
159                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
160                             "error login in, no token provided");
161                 } else if (userId == null) {
162                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
163                             "error login in, no userId provided");
164                 } else {
165                     token = localToken;
166                     return true;
167                 }
168             }
169         } catch (final MillheatCommunicationException e) {
170             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error login: " + e.getMessage());
171         }
172         return false;
173     }
174
175     @Override
176     public void initialize() {
177         config = getConfigAs(MillheatAccountConfiguration.class);
178         scheduler.execute(() -> {
179             if (doLogin()) {
180                 try {
181                     model = refreshModel();
182                     updateStatus(ThingStatus.ONLINE);
183                     initPolling();
184                 } catch (final MillheatCommunicationException e) {
185                     model = new MillheatModel(0); // Empty model
186                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
187                             "error fetching initial data " + e.getMessage());
188                     logger.debug("Error initializing Millheat data", e);
189                     // Reschedule init
190                     scheduler.schedule(() -> {
191                         initialize();
192                     }, 30, TimeUnit.SECONDS);
193                 }
194             }
195         });
196         logger.debug("Finished initializing!");
197     }
198
199     @Override
200     public void dispose() {
201         stopPolling();
202         super.dispose();
203     }
204
205     /**
206      * starts this things polling future
207      */
208     private void initPolling() {
209         stopPolling();
210         statusFuture = scheduler.scheduleWithFixedDelay(() -> {
211             try {
212                 updateModelFromServerWithRetry(true);
213             } catch (final RuntimeException e) {
214                 logger.debug("Error refreshing model", e);
215             }
216         }, config.refreshInterval, config.refreshInterval, TimeUnit.SECONDS);
217     }
218
219     private <T> T sendLoginRequest(final AbstractRequest req, final Class<T> responseType)
220             throws MillheatCommunicationException {
221         final Request request = httpClient.newRequest(authEndpoint + req.getRequestUrl());
222         addStandardHeadersAndPayload(request, req);
223         return sendRequest(request, req, responseType);
224     }
225
226     private <T> T sendLoggedInRequest(final AbstractRequest req, final Class<T> responseType)
227             throws MillheatCommunicationException {
228         try {
229             final Request request = buildLoggedInRequest(req);
230             return sendRequest(request, req, responseType);
231         } catch (NoSuchAlgorithmException e) {
232             throw new MillheatCommunicationException("Error building Millheat request: " + e.getMessage(), e);
233         }
234     }
235
236     @SuppressWarnings("unchecked")
237     private <T> T sendRequest(final Request request, final AbstractRequest req, final Class<T> responseType)
238             throws MillheatCommunicationException {
239         try {
240             final ContentResponse contentResponse = request.send();
241             final String responseJson = contentResponse.getContentAsString();
242             if (contentResponse.getStatus() == HttpStatus.OK_200) {
243                 final AbstractResponse rsp = (AbstractResponse) gson.fromJson(responseJson, responseType);
244                 if (rsp == null) {
245                     return (T) null;
246                 } else if (rsp.errorCode == 0) {
247                     return (T) rsp;
248                 } else {
249                     throw new MillheatCommunicationException(req, rsp);
250                 }
251             } else {
252                 throw new MillheatCommunicationException(
253                         "Error sending request to Millheat server. Server responded with " + contentResponse.getStatus()
254                                 + " and payload " + responseJson);
255             }
256         } catch (InterruptedException | TimeoutException | ExecutionException e) {
257             throw new MillheatCommunicationException("Error sending request to Millheat server: " + e.getMessage(), e);
258         }
259     }
260
261     public MillheatModel refreshModel() throws MillheatCommunicationException {
262         final MillheatModel model = new MillheatModel(System.currentTimeMillis());
263         final GetHomesResponse homesRsp = sendLoggedInRequest(new GetHomesRequest(), GetHomesResponse.class);
264         for (final HomeDTO dto : homesRsp.homes) {
265             model.addHome(new Home(dto));
266         }
267         for (final Home home : model.getHomes()) {
268             final SelectRoomByHomeResponse roomRsp = sendLoggedInRequest(
269                     new SelectRoomByHomeRequest(home.getId(), home.getTimezone()), SelectRoomByHomeResponse.class);
270             for (final RoomDTO dto : roomRsp.rooms) {
271                 home.addRoom(new Room(dto, home));
272             }
273
274             for (final Room room : home.getRooms()) {
275                 final SelectDeviceByRoomResponse deviceRsp = sendLoggedInRequest(
276                         new SelectDeviceByRoomRequest(room.getId(), home.getTimezone()),
277                         SelectDeviceByRoomResponse.class);
278                 for (final DeviceDTO dto : deviceRsp.devices) {
279                     room.addHeater(new Heater(dto, room));
280                 }
281             }
282             final GetIndependentDevicesByHomeResponse independentRsp = sendLoggedInRequest(
283                     new GetIndependentDevicesByHomeRequest(home.getId(), home.getTimezone()),
284                     GetIndependentDevicesByHomeResponse.class);
285             for (final DeviceDTO dto : independentRsp.devices) {
286                 home.addHeater(new Heater(dto));
287             }
288         }
289         return model;
290     }
291
292     /**
293      * Stops this thing's polling future
294      */
295     @SuppressWarnings("null")
296     private void stopPolling() {
297         if (statusFuture != null && !statusFuture.isCancelled()) {
298             statusFuture.cancel(true);
299             statusFuture = null;
300         }
301     }
302
303     public void updateModelFromServerWithRetry(boolean forceUpdate) {
304         if (allowModelUpdate() || forceUpdate) {
305             try {
306                 updateModel();
307             } catch (final MillheatCommunicationException e) {
308                 try {
309                     if (AbstractResponse.ERROR_CODE_ACCESS_TOKEN_EXPIRED == e.getErrorCode()
310                             || AbstractResponse.ERROR_CODE_INVALID_SIGNATURE == e.getErrorCode()
311                             || AbstractResponse.ERROR_CODE_AUTHENTICATION_FAILURE == e.getErrorCode()) {
312                         logger.debug("Token expired, will refresh token, then retry state refresh", e);
313                         if (doLogin()) {
314                             updateModel();
315                         }
316                     } else {
317                         logger.debug("Initiating retry due to error {}", e.getMessage(), e);
318                         updateModel();
319                     }
320                 } catch (MillheatCommunicationException e1) {
321                     logger.debug("Retry failed, waiting for next refresh cycle: {}", e.getMessage(), e);
322                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.getMessage());
323                 }
324             }
325         }
326     }
327
328     private void updateModel() throws MillheatCommunicationException {
329         model = refreshModel();
330         updateThingStatuses();
331         updateStatus(ThingStatus.ONLINE);
332     }
333
334     private void updateThingStatuses() {
335         final List<Thing> subThings = getThing().getThings();
336         for (final Thing thing : subThings) {
337             final ThingHandler handler = thing.getHandler();
338             if (handler != null) {
339                 final MillheatBaseThingHandler mHandler = (MillheatBaseThingHandler) handler;
340                 mHandler.updateState(model);
341             }
342         }
343     }
344
345     private Request buildLoggedInRequest(final AbstractRequest req) throws NoSuchAlgorithmException {
346         final String nonce = getRandomString(NUM_NONCE_CHARS);
347         final String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
348         final String signatureBasis = REQUEST_TIMEOUT + timestamp + nonce + token;
349         MessageDigest md = MessageDigest.getInstance(SHA_1_ALGORITHM);
350         byte[] sha1Hash = md.digest(signatureBasis.getBytes(StandardCharsets.UTF_8));
351         final String signature = HexUtils.bytesToHex(sha1Hash).toLowerCase();
352         final String reqJson = gson.toJson(req);
353
354         final Request request = httpClient.newRequest(serviceEndpoint + req.getRequestUrl());
355
356         return addStandardHeadersAndPayload(request, req).header("X-Zc-Timestamp", timestamp)
357                 .header("X-Zc-Timeout", REQUEST_TIMEOUT).header("X-Zc-Nonce", nonce).header("X-Zc-User-Id", userId)
358                 .header("X-Zc-User-Signature", signature).header("X-Zc-Content-Length", "" + reqJson.length());
359     }
360
361     private Request addStandardHeadersAndPayload(final Request req, final AbstractRequest payload) {
362         requestLogger.listenTo(req);
363
364         return req.header("Connection", "Keep-Alive").header("X-Zc-Major-Domain", "seanywell")
365                 .header("X-Zc-Msg-Name", "millService").header("X-Zc-Sub-Domain", "milltype").header("X-Zc-Seq-Id", "1")
366                 .header("X-Zc-Version", "1").method(HttpMethod.POST).timeout(30, TimeUnit.SECONDS)
367                 .content(new BytesContentProvider(gson.toJson(payload).getBytes(StandardCharsets.UTF_8)), CONTENT_TYPE);
368     }
369
370     public void updateRoomTemperature(final Long roomId, final Command command, final ModeType mode) {
371         final Optional<Home> optionalHome = model.findHomeByRoomId(roomId);
372         final Optional<Room> optionalRoom = model.findRoomById(roomId);
373         if (optionalHome.isPresent() && optionalRoom.isPresent()) {
374             final SetRoomTempRequest req = new SetRoomTempRequest(optionalHome.get(), optionalRoom.get());
375             if (command instanceof QuantityType<?> quantityCommand) {
376                 final int newTemp = (int) quantityCommand.longValue();
377                 switch (mode) {
378                     case SLEEP:
379                         req.sleepTemp = newTemp;
380                         break;
381                     case AWAY:
382                         req.awayTemp = newTemp;
383                         break;
384                     case COMFORT:
385                         req.comfortTemp = newTemp;
386                         break;
387                     default:
388                         logger.info("Cannot set room temp for mode {}", mode);
389                 }
390                 try {
391                     sendLoggedInRequest(req, SetRoomTempResponse.class);
392                 } catch (final MillheatCommunicationException e) {
393                     logger.debug("Error updating temperature for room {}", roomId, e);
394                 }
395             } else {
396                 logger.debug("Error updating temperature for room {}, expected QuantityType but got {}", roomId,
397                         command);
398             }
399         }
400     }
401
402     public void updateIndependentHeaterProperties(@Nullable final String macAddress, @Nullable final Long heaterId,
403             @Nullable final Command temperatureCommand, @Nullable final Command masterOnOffCommand,
404             @Nullable final Command fanCommand) {
405         model.findHeaterByMacOrId(macAddress, heaterId).ifPresent(heater -> {
406             int setTemp = heater.getTargetTemp();
407             if (temperatureCommand instanceof QuantityType<?> temperature) {
408                 setTemp = (int) temperature.longValue();
409             }
410             boolean masterOnOff = heater.powerStatus();
411             if (masterOnOffCommand != null) {
412                 masterOnOff = masterOnOffCommand == OnOffType.ON;
413             }
414             boolean fanActive = heater.fanActive();
415             if (fanCommand != null) {
416                 fanActive = fanCommand == OnOffType.ON;
417             }
418             final SetDeviceTempRequest req = new SetDeviceTempRequest(heater, setTemp, masterOnOff, fanActive);
419             try {
420                 sendLoggedInRequest(req, SetRoomTempResponse.class);
421                 heater.setTargetTemp(setTemp);
422                 heater.setPowerStatus(masterOnOff);
423                 heater.setFanActive(fanActive);
424             } catch (final MillheatCommunicationException e) {
425                 logger.debug("Error updating temperature for heater {}", macAddress, e);
426             }
427         });
428     }
429
430     public void updateVacationProperty(Home home, String property, Command command) {
431         try {
432             switch (property) {
433                 case SetHolidayParameterRequest.PROP_START: {
434                     long epoch = ((DateTimeType) command).getZonedDateTime().toEpochSecond();
435                     SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(), home.getTimezone(),
436                             SetHolidayParameterRequest.PROP_START, epoch);
437                     if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
438                         home.setVacationModeStart(epoch);
439                     }
440                     break;
441                 }
442                 case SetHolidayParameterRequest.PROP_END: {
443                     long epoch = ((DateTimeType) command).getZonedDateTime().toEpochSecond();
444                     SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(), home.getTimezone(),
445                             SetHolidayParameterRequest.PROP_END, epoch);
446                     if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
447                         home.setVacationModeEnd(epoch);
448                     }
449                     break;
450                 }
451                 case SetHolidayParameterRequest.PROP_TEMP: {
452                     int holidayTemp = ((QuantityType<?>) command).intValue();
453                     SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(), home.getTimezone(),
454                             SetHolidayParameterRequest.PROP_TEMP, holidayTemp);
455                     if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
456                         home.setHolidayTemp(holidayTemp);
457                     }
458                     break;
459                 }
460                 case SetHolidayParameterRequest.PROP_MODE_ADVANCED: {
461                     if (home.getMode().getMode() == ModeType.VACATION) {
462                         int value = OnOffType.ON == command ? 0 : 1;
463                         SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(),
464                                 home.getTimezone(), SetHolidayParameterRequest.PROP_MODE_ADVANCED, value);
465                         if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
466                             home.setVacationModeAdvanced((OnOffType) command);
467                         }
468                     } else {
469                         logger.debug("Must enable vaction mode before advanced vacation mode can be enabled");
470                     }
471                     break;
472                 }
473                 case SetHolidayParameterRequest.PROP_MODE: {
474                     if (home.getVacationModeStart() != null && home.getVacationModeEnd() != null) {
475                         int value = OnOffType.ON == command ? 1 : 0;
476                         SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(),
477                                 home.getTimezone(), SetHolidayParameterRequest.PROP_MODE, value);
478                         if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
479                             updateModelFromServerWithRetry(true);
480                         }
481                     } else {
482                         logger.debug("Cannot enable vacation mode unless start and end time is already set");
483                     }
484                     break;
485                 }
486             }
487         } catch (MillheatCommunicationException e) {
488             logger.debug("Failure trying to set holiday properties: {}", e.getMessage());
489         }
490     }
491 }