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