2 * Copyright (c) 2010-2020 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.plugwise.internal.handler;
15 import static java.util.stream.Collectors.*;
16 import static org.openhab.binding.plugwise.internal.PlugwiseBindingConstants.*;
17 import static org.openhab.core.thing.ThingStatus.*;
19 import java.time.Duration;
20 import java.time.LocalDateTime;
21 import java.util.Collections;
22 import java.util.List;
23 import java.util.concurrent.TimeUnit;
24 import java.util.stream.Stream;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.plugwise.internal.PlugwiseDeviceTask;
29 import org.openhab.binding.plugwise.internal.PlugwiseUtils;
30 import org.openhab.binding.plugwise.internal.config.PlugwiseRelayConfig;
31 import org.openhab.binding.plugwise.internal.config.PlugwiseRelayConfig.PowerStateChanging;
32 import org.openhab.binding.plugwise.internal.protocol.AcknowledgementMessage;
33 import org.openhab.binding.plugwise.internal.protocol.AcknowledgementMessage.ExtensionCode;
34 import org.openhab.binding.plugwise.internal.protocol.ClockGetRequestMessage;
35 import org.openhab.binding.plugwise.internal.protocol.ClockGetResponseMessage;
36 import org.openhab.binding.plugwise.internal.protocol.ClockSetRequestMessage;
37 import org.openhab.binding.plugwise.internal.protocol.InformationRequestMessage;
38 import org.openhab.binding.plugwise.internal.protocol.InformationResponseMessage;
39 import org.openhab.binding.plugwise.internal.protocol.Message;
40 import org.openhab.binding.plugwise.internal.protocol.PowerBufferRequestMessage;
41 import org.openhab.binding.plugwise.internal.protocol.PowerBufferResponseMessage;
42 import org.openhab.binding.plugwise.internal.protocol.PowerCalibrationRequestMessage;
43 import org.openhab.binding.plugwise.internal.protocol.PowerCalibrationResponseMessage;
44 import org.openhab.binding.plugwise.internal.protocol.PowerChangeRequestMessage;
45 import org.openhab.binding.plugwise.internal.protocol.PowerInformationRequestMessage;
46 import org.openhab.binding.plugwise.internal.protocol.PowerInformationResponseMessage;
47 import org.openhab.binding.plugwise.internal.protocol.PowerLogIntervalSetRequestMessage;
48 import org.openhab.binding.plugwise.internal.protocol.RealTimeClockGetRequestMessage;
49 import org.openhab.binding.plugwise.internal.protocol.RealTimeClockGetResponseMessage;
50 import org.openhab.binding.plugwise.internal.protocol.RealTimeClockSetRequestMessage;
51 import org.openhab.binding.plugwise.internal.protocol.field.DeviceType;
52 import org.openhab.binding.plugwise.internal.protocol.field.Energy;
53 import org.openhab.binding.plugwise.internal.protocol.field.MACAddress;
54 import org.openhab.binding.plugwise.internal.protocol.field.PowerCalibration;
55 import org.openhab.core.config.core.Configuration;
56 import org.openhab.core.library.types.OnOffType;
57 import org.openhab.core.library.types.QuantityType;
58 import org.openhab.core.library.types.StringType;
59 import org.openhab.core.library.unit.SmartHomeUnits;
60 import org.openhab.core.thing.Bridge;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.types.Command;
66 import org.openhab.core.types.UnDefType;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
72 * The {@link PlugwiseRelayDeviceHandler} handles channel updates and commands for a Plugwise device with a relay.
73 * Relay devices are the Circle, Circle+ and Stealth.
76 * A Circle maintains current energy usage by counting 'pulses' in a one or eight-second interval. Furthermore, it
77 * stores hourly energy usage as well in a buffer. Each entry in the buffer contains usage for the last 4 full hours of
78 * consumption. In order to convert pulses to energy (kWh) or power (W), a calculation is made in the {@link Energy}
79 * class with {@link PowerCalibration} data.
82 * A Circle+ is a special Circle. There is one Circle+ in a Plugwise network. The Circle+ serves as a master controller
83 * in a Plugwise network. It also provides clock data to the other devices and sends messages from and to the Stick.
86 * A Stealth behaves like a Circle but it has a more compact form factor.
89 * @author Wouter Born, Karel Goderis - Initial contribution
92 public class PlugwiseRelayDeviceHandler extends AbstractPlugwiseThingHandler {
94 private static final int INVALID_WATT_THRESHOLD = 10000;
95 private static final int POWER_STATE_RETRIES = 3;
97 private class PendingPowerStateChange {
98 final OnOffType onOff;
101 PendingPowerStateChange(OnOffType onOff) {
106 private final PlugwiseDeviceTask clockUpdateTask = new PlugwiseDeviceTask("Clock update", scheduler) {
108 public Duration getConfiguredInterval() {
109 return getChannelUpdateInterval(CHANNEL_CLOCK);
113 public void runTask() {
114 sendMessage(new ClockGetRequestMessage(macAddress));
118 public boolean shouldBeScheduled() {
119 return thing.getStatus() == ONLINE && isLinked(CHANNEL_CLOCK);
123 private final PlugwiseDeviceTask currentPowerUpdateTask = new PlugwiseDeviceTask("Current power update",
126 public Duration getConfiguredInterval() {
127 return getChannelUpdateInterval(CHANNEL_POWER);
131 public void runTask() {
132 if (isCalibrated()) {
133 sendMessage(new PowerInformationRequestMessage(macAddress));
138 public boolean shouldBeScheduled() {
139 return thing.getStatus() == ONLINE && (isLinked(CHANNEL_POWER)
140 || configuration.getPowerStateChanging() != PowerStateChanging.COMMAND_SWITCHING);
144 private final PlugwiseDeviceTask energyUpdateTask = new PlugwiseDeviceTask("Energy update", scheduler) {
146 public Duration getConfiguredInterval() {
147 return getChannelUpdateInterval(CHANNEL_ENERGY);
151 public void runTask() {
152 if (isRecentLogAddressKnown()) {
158 public boolean shouldBeScheduled() {
159 return thing.getStatus() == ONLINE && isLinked(CHANNEL_ENERGY);
163 private final PlugwiseDeviceTask informationUpdateTask = new PlugwiseDeviceTask("Information update", scheduler) {
165 public Duration getConfiguredInterval() {
166 return PlugwiseUtils.minComparable(getChannelUpdateInterval(CHANNEL_STATE),
167 getChannelUpdateInterval(CHANNEL_ENERGY));
171 public void runTask() {
176 public boolean shouldBeScheduled() {
177 return thing.getStatus() == ONLINE && (isLinked(CHANNEL_STATE) || isLinked(CHANNEL_ENERGY));
181 private final PlugwiseDeviceTask realTimeClockUpdateTask = new PlugwiseDeviceTask("Real-time clock update",
184 public Duration getConfiguredInterval() {
185 return getChannelUpdateInterval(CHANNEL_REAL_TIME_CLOCK);
189 public void runTask() {
190 sendMessage(new RealTimeClockGetRequestMessage(macAddress));
194 public boolean shouldBeScheduled() {
195 return thing.getStatus() == ONLINE && deviceType == DeviceType.CIRCLE_PLUS
196 && isLinked(CHANNEL_REAL_TIME_CLOCK);
200 private final PlugwiseDeviceTask setClockTask = new PlugwiseDeviceTask("Set clock", scheduler) {
202 public Duration getConfiguredInterval() {
203 return Duration.ofDays(1);
207 public void runTask() {
208 if (deviceType == DeviceType.CIRCLE_PLUS) {
209 // The Circle+ real-time clock needs to be updated first to prevent clock sync issues
210 sendCommandMessage(new RealTimeClockSetRequestMessage(macAddress, LocalDateTime.now()));
211 scheduler.schedule(() -> {
212 sendCommandMessage(new ClockSetRequestMessage(macAddress, LocalDateTime.now()));
213 }, 5, TimeUnit.SECONDS);
215 sendCommandMessage(new ClockSetRequestMessage(macAddress, LocalDateTime.now()));
220 public boolean shouldBeScheduled() {
221 return thing.getStatus() == ONLINE;
225 private final List<PlugwiseDeviceTask> recurringTasks = Stream
226 .of(clockUpdateTask, currentPowerUpdateTask, energyUpdateTask, informationUpdateTask,
227 realTimeClockUpdateTask, setClockTask)
228 .collect(collectingAndThen(toList(), Collections::unmodifiableList));
230 private final Logger logger = LoggerFactory.getLogger(PlugwiseRelayDeviceHandler.class);
231 private final DeviceType deviceType;
233 private int recentLogAddress = -1;
235 private @NonNullByDefault({}) PlugwiseRelayConfig configuration;
236 private @NonNullByDefault({}) MACAddress macAddress;
238 private @Nullable PowerCalibration calibration;
239 private @Nullable Energy energy;
240 private @Nullable PendingPowerStateChange pendingPowerStateChange;
242 // Flag that keeps track of the pending "measurement interval" device configuration update. When the corresponding
243 // Thing configuration parameter changes it is set to true. When the Circle/Stealth goes online a command is sent to
244 // update the device configuration. When the Circle/Stealth acknowledges the command the flag is again set to false.
245 private boolean updateMeasurementInterval;
247 public PlugwiseRelayDeviceHandler(Thing thing) {
249 deviceType = getDeviceType();
252 private void calibrate() {
253 sendFastUpdateMessage(new PowerCalibrationRequestMessage(macAddress));
257 public void channelLinked(ChannelUID channelUID) {
258 updateTasks(recurringTasks);
262 public void channelUnlinked(ChannelUID channelUID) {
263 updateTasks(recurringTasks);
266 private void correctPowerState(OnOffType powerState) {
267 if (configuration.getPowerStateChanging() == PowerStateChanging.ALWAYS_OFF && (powerState != OnOffType.OFF)) {
268 logger.debug("Correcting power state of {} ({}) to off", deviceType, macAddress);
269 handleOnOffCommand(OnOffType.OFF);
270 } else if (configuration.getPowerStateChanging() == PowerStateChanging.ALWAYS_ON
271 && (powerState != OnOffType.ON)) {
272 logger.debug("Correcting power state of {} ({}) to on", deviceType, macAddress);
273 handleOnOffCommand(OnOffType.ON);
277 private double correctSign(double value) {
278 return configuration.isSuppliesPower() ? -Math.abs(value) : Math.abs(value);
282 public void dispose() {
283 stopTasks(recurringTasks);
288 protected MACAddress getMACAddress() {
292 private void handleAcknowledgement(AcknowledgementMessage message) {
293 boolean oldConfigurationPending = isConfigurationPending();
295 ExtensionCode extensionCode = message.getExtensionCode();
296 switch (extensionCode) {
298 logger.debug("Received ACK for clock set of {} ({})", deviceType, macAddress);
299 sendMessage(new ClockGetRequestMessage(macAddress));
302 logger.debug("Received ACK for switching on {} ({})", deviceType, macAddress);
303 updateState(CHANNEL_STATE, OnOffType.ON);
306 logger.debug("Received NACK for switching on/off {} ({})", deviceType, macAddress);
309 logger.debug("Received ACK for switching off {} ({})", deviceType, macAddress);
310 updateState(CHANNEL_STATE, OnOffType.OFF);
312 case POWER_LOG_INTERVAL_SET_ACK:
313 logger.debug("Received ACK for power log interval set of {} ({})", deviceType, macAddress);
314 updateMeasurementInterval = false;
316 case REAL_TIME_CLOCK_SET_ACK:
317 logger.debug("Received ACK for setting real-time clock of {} ({})", deviceType, macAddress);
318 sendMessage(new RealTimeClockGetRequestMessage(macAddress));
320 case REAL_TIME_CLOCK_SET_NACK:
321 logger.debug("Received NACK for setting real-time clock of {} ({})", deviceType, macAddress);
324 logger.debug("{} ({}) {} acknowledgement", deviceType, macAddress, extensionCode);
328 boolean newConfigurationPending = isConfigurationPending();
330 if (oldConfigurationPending != newConfigurationPending && !newConfigurationPending) {
331 Configuration newConfiguration = editConfiguration();
332 newConfiguration.put(CONFIG_PROPERTY_UPDATE_CONFIGURATION, false);
333 updateConfiguration(newConfiguration);
336 updateStatusOnDetailChange();
339 private void handleCalibrationResponse(PowerCalibrationResponseMessage message) {
340 boolean wasCalibrated = isCalibrated();
341 calibration = message.getCalibration();
342 logger.debug("{} ({}) calibrated: {}", deviceType, macAddress, calibration);
343 if (!wasCalibrated) {
344 if (isRecentLogAddressKnown()) {
349 sendFastUpdateMessage(new PowerInformationRequestMessage(macAddress));
353 private void handleClockGetResponse(ClockGetResponseMessage message) {
354 updateState(CHANNEL_CLOCK, new StringType(message.getTime()));
358 public void handleCommand(ChannelUID channelUID, Command command) {
359 logger.debug("Handling command '{}' for {} ({}) channel '{}'", command, deviceType, macAddress,
361 if (CHANNEL_STATE.equals(channelUID.getId()) && (command instanceof OnOffType)) {
362 if (configuration.getPowerStateChanging() == PowerStateChanging.COMMAND_SWITCHING) {
363 OnOffType onOff = (OnOffType) command;
364 pendingPowerStateChange = new PendingPowerStateChange(onOff);
365 handleOnOffCommand(onOff);
367 OnOffType onOff = configuration.getPowerStateChanging() == PowerStateChanging.ALWAYS_ON ? OnOffType.ON
369 logger.debug("Ignoring {} ({}) power state change (always {})", deviceType, macAddress, onOff);
370 updateState(CHANNEL_STATE, onOff);
375 private void handleInformationResponse(InformationResponseMessage message) {
376 recentLogAddress = message.getLogAddress();
377 OnOffType powerState = message.getPowerState() ? OnOffType.ON : OnOffType.OFF;
379 PendingPowerStateChange change = pendingPowerStateChange;
380 if (change != null) {
381 if (powerState == change.onOff) {
382 pendingPowerStateChange = null;
384 // Power state change message may be lost or the informationUpdateTask may have queried the power
385 // state just before the power state change message arrived
386 if (change.retries < POWER_STATE_RETRIES) {
388 logger.warn("Retrying to switch {} ({}) {} (retry #{})", deviceType, macAddress, change.onOff,
390 handleOnOffCommand(change.onOff);
392 logger.warn("Failed to switch {} ({}) {} after {} retries", deviceType, macAddress, change.onOff,
394 pendingPowerStateChange = null;
399 if (pendingPowerStateChange == null) {
400 updateState(CHANNEL_STATE, powerState);
401 correctPowerState(powerState);
404 if (energy == null && isCalibrated()) {
408 updateProperties(message);
411 private void handleOnOffCommand(OnOffType command) {
412 sendCommandMessage(new PowerChangeRequestMessage(macAddress, command == OnOffType.ON));
413 sendFastUpdateMessage(new InformationRequestMessage(macAddress));
415 // Measurements take 2 seconds to become stable
416 scheduler.schedule(() -> sendFastUpdateMessage(new PowerInformationRequestMessage(macAddress)), 2,
420 private void handlePowerBufferResponse(PowerBufferResponseMessage message) {
421 PowerCalibration localCalibration = calibration;
422 if (localCalibration == null) {
427 Energy mostRecentEnergy = message.getMostRecentDatapoint();
429 if (mostRecentEnergy != null) {
430 // When the current time is '11:44:55.888' and the measurement interval 1 hour, then the end of the most
431 // recent energy measurement interval is at '11:00:00.000'
432 LocalDateTime oneIntervalAgo = LocalDateTime.now().minus(configuration.getMeasurementInterval());
434 boolean isLastInterval = mostRecentEnergy.getEnd().isAfter(oneIntervalAgo);
435 if (isLastInterval) {
436 mostRecentEnergy.setInterval(configuration.getMeasurementInterval());
437 energy = mostRecentEnergy;
438 logger.trace("Updating {} ({}) energy with: {}", deviceType, macAddress, mostRecentEnergy);
439 updateState(CHANNEL_ENERGY, new QuantityType<>(correctSign(mostRecentEnergy.tokWh(localCalibration)),
440 SmartHomeUnits.KILOWATT_HOUR));
441 LocalDateTime start = mostRecentEnergy.getStart();
442 updateState(CHANNEL_ENERGY_STAMP,
443 start != null ? PlugwiseUtils.newDateTimeType(start) : UnDefType.NULL);
445 logger.trace("Most recent energy in buffer of {} ({}) is older than one interval ago: {}", deviceType,
446 macAddress, mostRecentEnergy);
449 logger.trace("Most recent energy in buffer of {} ({}) is null", deviceType, macAddress);
453 private void handlePowerInformationResponse(PowerInformationResponseMessage message) {
454 PowerCalibration localCalibration = calibration;
455 if (localCalibration == null) {
460 Energy one = message.getOneSecond();
461 double watt = one.toWatt(localCalibration);
462 if (watt > INVALID_WATT_THRESHOLD) {
463 logger.debug("{} ({}) is in a kind of error state, skipping power information response", deviceType,
468 updateState(CHANNEL_POWER, new QuantityType<>(correctSign(watt), SmartHomeUnits.WATT));
471 private void handleRealTimeClockGetResponse(RealTimeClockGetResponseMessage message) {
472 updateState(CHANNEL_REAL_TIME_CLOCK, PlugwiseUtils.newDateTimeType(message.getDateTime()));
476 public void handleReponseMessage(Message message) {
479 switch (message.getType()) {
480 case ACKNOWLEDGEMENT_V1:
481 case ACKNOWLEDGEMENT_V2:
482 handleAcknowledgement((AcknowledgementMessage) message);
484 case CLOCK_GET_RESPONSE:
485 handleClockGetResponse(((ClockGetResponseMessage) message));
487 case DEVICE_INFORMATION_RESPONSE:
488 handleInformationResponse((InformationResponseMessage) message);
490 case POWER_BUFFER_RESPONSE:
491 handlePowerBufferResponse((PowerBufferResponseMessage) message);
493 case POWER_CALIBRATION_RESPONSE:
494 handleCalibrationResponse(((PowerCalibrationResponseMessage) message));
496 case POWER_INFORMATION_RESPONSE:
497 handlePowerInformationResponse((PowerInformationResponseMessage) message);
499 case REAL_TIME_CLOCK_GET_RESPONSE:
500 handleRealTimeClockGetResponse((RealTimeClockGetResponseMessage) message);
503 logger.trace("Received unhandled {} message from {} ({})", message.getType(), deviceType, macAddress);
509 public void initialize() {
510 configuration = getConfigAs(PlugwiseRelayConfig.class);
511 macAddress = configuration.getMACAddress();
512 if (!isInitialized()) {
513 setUpdateCommandFlags(null, configuration);
515 if (configuration.isTemporarilyNotInNetwork()) {
516 updateStatus(OFFLINE);
518 updateTasks(recurringTasks);
522 private boolean isCalibrated() {
523 return calibration != null;
527 protected boolean isConfigurationPending() {
528 return updateMeasurementInterval;
531 private boolean isRecentLogAddressKnown() {
532 return recentLogAddress >= 0;
536 protected void sendConfigurationUpdateCommands() {
537 logger.debug("Sending {} ({}) configuration update commands", deviceType, macAddress);
539 if (updateMeasurementInterval) {
540 logger.debug("Sending command to update {} ({}) power log measurement interval", deviceType, macAddress);
541 Duration consumptionInterval = configuration.isSuppliesPower() ? Duration.ZERO
542 : configuration.getMeasurementInterval();
543 Duration productionInterval = configuration.isSuppliesPower() ? configuration.getMeasurementInterval()
546 new PowerLogIntervalSetRequestMessage(macAddress, consumptionInterval, productionInterval));
549 super.sendConfigurationUpdateCommands();
552 private void setUpdateCommandFlags(@Nullable PlugwiseRelayConfig oldConfiguration,
553 PlugwiseRelayConfig newConfiguration) {
554 boolean fullUpdate = newConfiguration.isUpdateConfiguration() && !isConfigurationPending();
556 logger.debug("Updating all configuration properties of {} ({})", deviceType, macAddress);
559 updateMeasurementInterval = fullUpdate || (oldConfiguration != null
560 && (!oldConfiguration.getMeasurementInterval().equals(newConfiguration.getMeasurementInterval())));
561 if (updateMeasurementInterval) {
562 logger.debug("Updating {} ({}) power log interval when online", deviceType, macAddress);
567 protected boolean shouldOnlineTaskBeScheduled() {
568 Bridge bridge = getBridge();
569 return !configuration.isTemporarilyNotInNetwork() && (bridge != null && bridge.getStatus() == ONLINE);
573 protected void updateConfiguration(Configuration configuration) {
574 PlugwiseRelayConfig oldConfiguration = this.configuration;
575 PlugwiseRelayConfig newConfiguration = configuration.as(PlugwiseRelayConfig.class);
577 setUpdateCommandFlags(oldConfiguration, newConfiguration);
579 configuration.put(CONFIG_PROPERTY_UPDATE_CONFIGURATION, isConfigurationPending());
581 super.updateConfiguration(configuration);
584 private void updateEnergy() {
585 int previousLogAddress = recentLogAddress - 1;
586 while (previousLogAddress <= recentLogAddress) {
587 PowerBufferRequestMessage message = new PowerBufferRequestMessage(macAddress, previousLogAddress);
588 previousLogAddress = previousLogAddress + 1;
589 sendMessage(message);
594 protected void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
595 super.updateStatus(status, statusDetail, description);
597 if (status == ONLINE) {
598 if (!isCalibrated()) {
601 if (editProperties().isEmpty()) {
606 updateTasks(recurringTasks);