2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.millheat.internal.handler;
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;
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;
79 import com.google.gson.Gson;
80 import com.google.gson.GsonBuilder;
83 * The {@link MillheatAccountHandler} is responsible for handling commands, which are
84 * sent to one of the channels.
86 * @author Arne Seime - Initial contribution
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;
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)));
115 return sb.toString();
118 public MillheatAccountHandler(final Bridge bridge, final HttpClient httpClient, final BundleContext context) {
120 this.httpClient = httpClient;
121 final BooleanSerializer serializer = new BooleanSerializer();
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);
129 private boolean allowModelUpdate() {
130 final long timeSinceLastUpdate = System.currentTimeMillis() - model.getLastUpdated();
131 return timeSinceLastUpdate > MIN_TIME_BETWEEEN_MODEL_UPDATES_MS;
134 public MillheatModel getModel() {
139 public void handleCommand(final ChannelUID channelUID, final Command command) {
140 logger.debug("Bridge does not support any commands, but received command {} for channelUID {}", command,
144 public boolean doLogin() {
146 final LoginResponse rsp = sendLoginRequest(new LoginRequest(config.username, config.password),
147 LoginResponse.class);
148 final int errorCode = rsp.errorCode;
149 if (errorCode != 0) {
150 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
151 String.format("Error login in: code=%s, type=%s, message=%s", errorCode, rsp.errorName,
152 rsp.errorDescription));
154 // No error provided on login, proceed to find token and userid
155 String localToken = rsp.token.trim();
156 userId = rsp.userId == null ? null : rsp.userId.toString();
157 if (localToken == null || localToken.isEmpty()) {
158 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
159 "error login in, no token provided");
160 } else if (userId == null) {
161 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
162 "error login in, no userId provided");
168 } catch (final MillheatCommunicationException e) {
169 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error login: " + e.getMessage());
175 public void initialize() {
176 config = getConfigAs(MillheatAccountConfiguration.class);
177 scheduler.execute(() -> {
180 model = refreshModel();
181 updateStatus(ThingStatus.ONLINE);
183 } catch (final MillheatCommunicationException e) {
184 model = new MillheatModel(0); // Empty model
185 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
186 "error fetching initial data " + e.getMessage());
187 logger.debug("Error initializing Millheat data", e);
189 scheduler.schedule(() -> {
191 }, 30, TimeUnit.SECONDS);
195 logger.debug("Finished initializing!");
199 public void dispose() {
205 * starts this things polling future
207 private void initPolling() {
209 statusFuture = scheduler.scheduleWithFixedDelay(() -> {
211 updateModelFromServerWithRetry(true);
212 } catch (final RuntimeException e) {
213 logger.debug("Error refreshing model", e);
215 }, config.refreshInterval, config.refreshInterval, TimeUnit.SECONDS);
218 private <T> T sendLoginRequest(final AbstractRequest req, final Class<T> responseType)
219 throws MillheatCommunicationException {
220 final Request request = httpClient.newRequest(authEndpoint + req.getRequestUrl());
221 addStandardHeadersAndPayload(request, req);
222 return sendRequest(request, req, responseType);
225 private <T> T sendLoggedInRequest(final AbstractRequest req, final Class<T> responseType)
226 throws MillheatCommunicationException {
228 final Request request = buildLoggedInRequest(req);
229 return sendRequest(request, req, responseType);
230 } catch (NoSuchAlgorithmException e) {
231 throw new MillheatCommunicationException("Error building Millheat request: " + e.getMessage(), e);
235 @SuppressWarnings("unchecked")
236 private <T> T sendRequest(final Request request, final AbstractRequest req, final Class<T> responseType)
237 throws MillheatCommunicationException {
239 final ContentResponse contentResponse = request.send();
240 final String responseJson = contentResponse.getContentAsString();
241 if (contentResponse.getStatus() == HttpStatus.OK_200) {
242 final AbstractResponse rsp = (AbstractResponse) gson.fromJson(responseJson, responseType);
245 } else if (rsp.errorCode == 0) {
248 throw new MillheatCommunicationException(req, rsp);
251 throw new MillheatCommunicationException(
252 "Error sending request to Millheat server. Server responded with " + contentResponse.getStatus()
253 + " and payload " + responseJson);
255 } catch (InterruptedException | TimeoutException | ExecutionException e) {
256 throw new MillheatCommunicationException("Error sending request to Millheat server: " + e.getMessage(), e);
260 public MillheatModel refreshModel() throws MillheatCommunicationException {
261 final MillheatModel model = new MillheatModel(System.currentTimeMillis());
262 final GetHomesResponse homesRsp = sendLoggedInRequest(new GetHomesRequest(), GetHomesResponse.class);
263 for (final HomeDTO dto : homesRsp.homes) {
264 model.addHome(new Home(dto));
266 for (final Home home : model.getHomes()) {
267 final SelectRoomByHomeResponse roomRsp = sendLoggedInRequest(
268 new SelectRoomByHomeRequest(home.getId(), home.getTimezone()), SelectRoomByHomeResponse.class);
269 for (final RoomDTO dto : roomRsp.rooms) {
270 home.addRoom(new Room(dto, home));
273 for (final Room room : home.getRooms()) {
274 final SelectDeviceByRoomResponse deviceRsp = sendLoggedInRequest(
275 new SelectDeviceByRoomRequest(room.getId(), home.getTimezone()),
276 SelectDeviceByRoomResponse.class);
277 for (final DeviceDTO dto : deviceRsp.devices) {
278 room.addHeater(new Heater(dto, room));
281 final GetIndependentDevicesByHomeResponse independentRsp = sendLoggedInRequest(
282 new GetIndependentDevicesByHomeRequest(home.getId(), home.getTimezone()),
283 GetIndependentDevicesByHomeResponse.class);
284 for (final DeviceDTO dto : independentRsp.devices) {
285 home.addHeater(new Heater(dto));
292 * Stops this thing's polling future
294 @SuppressWarnings("null")
295 private void stopPolling() {
296 if (statusFuture != null && !statusFuture.isCancelled()) {
297 statusFuture.cancel(true);
302 public void updateModelFromServerWithRetry(boolean forceUpdate) {
303 if (allowModelUpdate() || forceUpdate) {
306 } catch (final MillheatCommunicationException e) {
308 if (AbstractResponse.ERROR_CODE_ACCESS_TOKEN_EXPIRED == e.getErrorCode()
309 || AbstractResponse.ERROR_CODE_INVALID_SIGNATURE == e.getErrorCode()
310 || AbstractResponse.ERROR_CODE_AUTHENTICATION_FAILURE == e.getErrorCode()) {
311 logger.debug("Token expired, will refresh token, then retry state refresh", e);
316 logger.debug("Initiating retry due to error {}", e.getMessage(), e);
319 } catch (MillheatCommunicationException e1) {
320 logger.debug("Retry failed, waiting for next refresh cycle: {}", e.getMessage(), e);
321 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.getMessage());
327 private void updateModel() throws MillheatCommunicationException {
328 model = refreshModel();
329 updateThingStatuses();
330 updateStatus(ThingStatus.ONLINE);
333 private void updateThingStatuses() {
334 final List<Thing> subThings = getThing().getThings();
335 for (final Thing thing : subThings) {
336 final ThingHandler handler = thing.getHandler();
337 if (handler != null) {
338 final MillheatBaseThingHandler mHandler = (MillheatBaseThingHandler) handler;
339 mHandler.updateState(model);
344 private Request buildLoggedInRequest(final AbstractRequest req) throws NoSuchAlgorithmException {
345 final String nonce = getRandomString(NUM_NONCE_CHARS);
346 final String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
347 final String signatureBasis = REQUEST_TIMEOUT + timestamp + nonce + token;
348 MessageDigest md = MessageDigest.getInstance(SHA_1_ALGORITHM);
349 byte[] sha1Hash = md.digest(signatureBasis.getBytes(StandardCharsets.UTF_8));
350 final String signature = HexUtils.bytesToHex(sha1Hash).toLowerCase();
351 final String reqJson = gson.toJson(req);
353 final Request request = httpClient.newRequest(serviceEndpoint + req.getRequestUrl());
355 return addStandardHeadersAndPayload(request, req).header("X-Zc-Timestamp", timestamp)
356 .header("X-Zc-Timeout", REQUEST_TIMEOUT).header("X-Zc-Nonce", nonce).header("X-Zc-User-Id", userId)
357 .header("X-Zc-User-Signature", signature).header("X-Zc-Content-Length", "" + reqJson.length());
360 private Request addStandardHeadersAndPayload(final Request req, final AbstractRequest payload) {
361 requestLogger.listenTo(req);
363 return req.header("Connection", "Keep-Alive").header("X-Zc-Major-Domain", "seanywell")
364 .header("X-Zc-Msg-Name", "millService").header("X-Zc-Sub-Domain", "milltype").header("X-Zc-Seq-Id", "1")
365 .header("X-Zc-Version", "1").method(HttpMethod.POST).timeout(30, TimeUnit.SECONDS)
366 .content(new BytesContentProvider(gson.toJson(payload).getBytes(StandardCharsets.UTF_8)), CONTENT_TYPE);
369 public void updateRoomTemperature(final Long roomId, final Command command, final ModeType mode) {
370 final Optional<Home> optionalHome = model.findHomeByRoomId(roomId);
371 final Optional<Room> optionalRoom = model.findRoomById(roomId);
372 if (optionalHome.isPresent() && optionalRoom.isPresent()) {
373 final SetRoomTempRequest req = new SetRoomTempRequest(optionalHome.get(), optionalRoom.get());
374 if (command instanceof QuantityType<?>) {
375 final int newTemp = (int) ((QuantityType<?>) command).longValue();
378 req.sleepTemp = newTemp;
381 req.awayTemp = newTemp;
384 req.comfortTemp = newTemp;
387 logger.info("Cannot set room temp for mode {}", mode);
390 sendLoggedInRequest(req, SetRoomTempResponse.class);
391 } catch (final MillheatCommunicationException e) {
392 logger.debug("Error updating temperature for room {}", roomId, e);
395 logger.debug("Error updating temperature for room {}, expected QuantityType but got {}", roomId,
401 public void updateIndependentHeaterProperties(@Nullable final String macAddress, @Nullable final Long heaterId,
402 @Nullable final Command temperatureCommand, @Nullable final Command masterOnOffCommand,
403 @Nullable final Command fanCommand) {
404 model.findHeaterByMacOrId(macAddress, heaterId).ifPresent(heater -> {
405 int setTemp = heater.getTargetTemp();
406 if (temperatureCommand instanceof QuantityType<?>) {
407 setTemp = (int) ((QuantityType<?>) temperatureCommand).longValue();
409 boolean masterOnOff = heater.powerStatus();
410 if (masterOnOffCommand != null) {
411 masterOnOff = masterOnOffCommand == OnOffType.ON;
413 boolean fanActive = heater.fanActive();
414 if (fanCommand != null) {
415 fanActive = fanCommand == OnOffType.ON;
417 final SetDeviceTempRequest req = new SetDeviceTempRequest(heater, setTemp, masterOnOff, fanActive);
419 sendLoggedInRequest(req, SetRoomTempResponse.class);
420 heater.setTargetTemp(setTemp);
421 heater.setPowerStatus(masterOnOff);
422 heater.setFanActive(fanActive);
423 } catch (final MillheatCommunicationException e) {
424 logger.debug("Error updating temperature for heater {}", macAddress, e);
429 public void updateVacationProperty(Home home, String property, Command command) {
432 case SetHolidayParameterRequest.PROP_START: {
433 long epoch = ((DateTimeType) command).getZonedDateTime().toEpochSecond();
434 SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(), home.getTimezone(),
435 SetHolidayParameterRequest.PROP_START, epoch);
436 if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
437 home.setVacationModeStart(epoch);
441 case SetHolidayParameterRequest.PROP_END: {
442 long epoch = ((DateTimeType) command).getZonedDateTime().toEpochSecond();
443 SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(), home.getTimezone(),
444 SetHolidayParameterRequest.PROP_END, epoch);
445 if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
446 home.setVacationModeEnd(epoch);
450 case SetHolidayParameterRequest.PROP_TEMP: {
451 int holidayTemp = ((QuantityType<?>) command).intValue();
452 SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(), home.getTimezone(),
453 SetHolidayParameterRequest.PROP_TEMP, holidayTemp);
454 if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
455 home.setHolidayTemp(holidayTemp);
459 case SetHolidayParameterRequest.PROP_MODE_ADVANCED: {
460 if (home.getMode().getMode() == ModeType.VACATION) {
461 int value = OnOffType.ON == command ? 0 : 1;
462 SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(),
463 home.getTimezone(), SetHolidayParameterRequest.PROP_MODE_ADVANCED, value);
464 if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
465 home.setVacationModeAdvanced((OnOffType) command);
468 logger.debug("Must enable vaction mode before advanced vacation mode can be enabled");
472 case SetHolidayParameterRequest.PROP_MODE: {
473 if (home.getVacationModeStart() != null && home.getVacationModeEnd() != null) {
474 int value = OnOffType.ON == command ? 1 : 0;
475 SetHolidayParameterRequest req = new SetHolidayParameterRequest(home.getId(),
476 home.getTimezone(), SetHolidayParameterRequest.PROP_MODE, value);
477 if (sendLoggedInRequest(req, SetHolidayParameterResponse.class).isSuccess()) {
478 updateModelFromServerWithRetry(true);
481 logger.debug("Cannot enable vacation mode unless start and end time is already set");
486 } catch (MillheatCommunicationException e) {
487 logger.debug("Failure trying to set holiday properties: {}", e.getMessage());