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.plugwise.internal.handler;
15 import static org.openhab.binding.plugwise.internal.PlugwiseBindingConstants.*;
16 import static org.openhab.core.thing.ThingStatus.*;
18 import java.time.Duration;
19 import java.time.LocalDateTime;
20 import java.util.List;
21 import java.util.concurrent.TimeUnit;
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.openhab.binding.plugwise.internal.PlugwiseDeviceTask;
26 import org.openhab.binding.plugwise.internal.PlugwiseUtils;
27 import org.openhab.binding.plugwise.internal.config.PlugwiseRelayConfig;
28 import org.openhab.binding.plugwise.internal.config.PlugwiseRelayConfig.PowerStateChanging;
29 import org.openhab.binding.plugwise.internal.protocol.AcknowledgementMessage;
30 import org.openhab.binding.plugwise.internal.protocol.AcknowledgementMessage.ExtensionCode;
31 import org.openhab.binding.plugwise.internal.protocol.ClockGetRequestMessage;
32 import org.openhab.binding.plugwise.internal.protocol.ClockGetResponseMessage;
33 import org.openhab.binding.plugwise.internal.protocol.ClockSetRequestMessage;
34 import org.openhab.binding.plugwise.internal.protocol.InformationRequestMessage;
35 import org.openhab.binding.plugwise.internal.protocol.InformationResponseMessage;
36 import org.openhab.binding.plugwise.internal.protocol.Message;
37 import org.openhab.binding.plugwise.internal.protocol.PowerBufferRequestMessage;
38 import org.openhab.binding.plugwise.internal.protocol.PowerBufferResponseMessage;
39 import org.openhab.binding.plugwise.internal.protocol.PowerCalibrationRequestMessage;
40 import org.openhab.binding.plugwise.internal.protocol.PowerCalibrationResponseMessage;
41 import org.openhab.binding.plugwise.internal.protocol.PowerChangeRequestMessage;
42 import org.openhab.binding.plugwise.internal.protocol.PowerInformationRequestMessage;
43 import org.openhab.binding.plugwise.internal.protocol.PowerInformationResponseMessage;
44 import org.openhab.binding.plugwise.internal.protocol.PowerLogIntervalSetRequestMessage;
45 import org.openhab.binding.plugwise.internal.protocol.RealTimeClockGetRequestMessage;
46 import org.openhab.binding.plugwise.internal.protocol.RealTimeClockGetResponseMessage;
47 import org.openhab.binding.plugwise.internal.protocol.RealTimeClockSetRequestMessage;
48 import org.openhab.binding.plugwise.internal.protocol.field.DeviceType;
49 import org.openhab.binding.plugwise.internal.protocol.field.Energy;
50 import org.openhab.binding.plugwise.internal.protocol.field.MACAddress;
51 import org.openhab.binding.plugwise.internal.protocol.field.PowerCalibration;
52 import org.openhab.core.config.core.Configuration;
53 import org.openhab.core.library.types.OnOffType;
54 import org.openhab.core.library.types.QuantityType;
55 import org.openhab.core.library.types.StringType;
56 import org.openhab.core.library.unit.Units;
57 import org.openhab.core.thing.Bridge;
58 import org.openhab.core.thing.ChannelUID;
59 import org.openhab.core.thing.Thing;
60 import org.openhab.core.thing.ThingStatus;
61 import org.openhab.core.thing.ThingStatusDetail;
62 import org.openhab.core.types.Command;
63 import org.openhab.core.types.UnDefType;
64 import org.slf4j.Logger;
65 import org.slf4j.LoggerFactory;
69 * The {@link PlugwiseRelayDeviceHandler} handles channel updates and commands for a Plugwise device with a relay.
70 * Relay devices are the Circle, Circle+ and Stealth.
73 * A Circle maintains current energy usage by counting 'pulses' in a one or eight-second interval. Furthermore, it
74 * stores hourly energy usage as well in a buffer. Each entry in the buffer contains usage for the last 4 full hours of
75 * consumption. In order to convert pulses to energy (kWh) or power (W), a calculation is made in the {@link Energy}
76 * class with {@link PowerCalibration} data.
79 * A Circle+ is a special Circle. There is one Circle+ in a Plugwise network. The Circle+ serves as a master controller
80 * in a Plugwise network. It also provides clock data to the other devices and sends messages from and to the Stick.
83 * A Stealth behaves like a Circle but it has a more compact form factor.
86 * @author Wouter Born, Karel Goderis - Initial contribution
89 public class PlugwiseRelayDeviceHandler extends AbstractPlugwiseThingHandler {
91 private static final int INVALID_WATT_THRESHOLD = 10000;
92 private static final int POWER_STATE_RETRIES = 3;
94 private class PendingPowerStateChange {
95 final OnOffType onOff;
98 PendingPowerStateChange(OnOffType onOff) {
103 private final PlugwiseDeviceTask clockUpdateTask = new PlugwiseDeviceTask("Clock update", scheduler) {
105 public Duration getConfiguredInterval() {
106 return getChannelUpdateInterval(CHANNEL_CLOCK);
110 public void runTask() {
111 sendMessage(new ClockGetRequestMessage(macAddress));
115 public boolean shouldBeScheduled() {
116 return thing.getStatus() == ONLINE && isLinked(CHANNEL_CLOCK);
120 private final PlugwiseDeviceTask currentPowerUpdateTask = new PlugwiseDeviceTask("Current power update",
123 public Duration getConfiguredInterval() {
124 return getChannelUpdateInterval(CHANNEL_POWER);
128 public void runTask() {
129 if (isCalibrated()) {
130 sendMessage(new PowerInformationRequestMessage(macAddress));
135 public boolean shouldBeScheduled() {
136 return thing.getStatus() == ONLINE && (isLinked(CHANNEL_POWER)
137 || configuration.getPowerStateChanging() != PowerStateChanging.COMMAND_SWITCHING);
141 private final PlugwiseDeviceTask energyUpdateTask = new PlugwiseDeviceTask("Energy update", scheduler) {
143 public Duration getConfiguredInterval() {
144 return getChannelUpdateInterval(CHANNEL_ENERGY);
148 public void runTask() {
149 if (isRecentLogAddressKnown()) {
155 public boolean shouldBeScheduled() {
156 return thing.getStatus() == ONLINE && isLinked(CHANNEL_ENERGY);
160 private final PlugwiseDeviceTask informationUpdateTask = new PlugwiseDeviceTask("Information update", scheduler) {
162 public Duration getConfiguredInterval() {
163 return PlugwiseUtils.minComparable(getChannelUpdateInterval(CHANNEL_STATE),
164 getChannelUpdateInterval(CHANNEL_ENERGY));
168 public void runTask() {
173 public boolean shouldBeScheduled() {
174 return thing.getStatus() == ONLINE && (isLinked(CHANNEL_STATE) || isLinked(CHANNEL_ENERGY));
178 private final PlugwiseDeviceTask realTimeClockUpdateTask = new PlugwiseDeviceTask("Real-time clock update",
181 public Duration getConfiguredInterval() {
182 return getChannelUpdateInterval(CHANNEL_REAL_TIME_CLOCK);
186 public void runTask() {
187 sendMessage(new RealTimeClockGetRequestMessage(macAddress));
191 public boolean shouldBeScheduled() {
192 return thing.getStatus() == ONLINE && deviceType == DeviceType.CIRCLE_PLUS
193 && isLinked(CHANNEL_REAL_TIME_CLOCK);
197 private final PlugwiseDeviceTask setClockTask = new PlugwiseDeviceTask("Set clock", scheduler) {
199 public Duration getConfiguredInterval() {
200 return Duration.ofDays(1);
204 public void runTask() {
205 if (deviceType == DeviceType.CIRCLE_PLUS) {
206 // The Circle+ real-time clock needs to be updated first to prevent clock sync issues
207 sendCommandMessage(new RealTimeClockSetRequestMessage(macAddress, LocalDateTime.now()));
209 () -> sendCommandMessage(new ClockSetRequestMessage(macAddress, LocalDateTime.now())), 5,
212 sendCommandMessage(new ClockSetRequestMessage(macAddress, LocalDateTime.now()));
217 public boolean shouldBeScheduled() {
218 return thing.getStatus() == ONLINE;
222 private final List<PlugwiseDeviceTask> recurringTasks = List.of(clockUpdateTask, currentPowerUpdateTask,
223 energyUpdateTask, informationUpdateTask, realTimeClockUpdateTask, setClockTask);
225 private final Logger logger = LoggerFactory.getLogger(PlugwiseRelayDeviceHandler.class);
226 private final DeviceType deviceType;
228 private int recentLogAddress = -1;
230 private @NonNullByDefault({}) PlugwiseRelayConfig configuration;
231 private @NonNullByDefault({}) MACAddress macAddress;
233 private @Nullable PowerCalibration calibration;
234 private @Nullable Energy energy;
235 private @Nullable PendingPowerStateChange pendingPowerStateChange;
237 // Flag that keeps track of the pending "measurement interval" device configuration update. When the corresponding
238 // Thing configuration parameter changes it is set to true. When the Circle/Stealth goes online a command is sent to
239 // update the device configuration. When the Circle/Stealth acknowledges the command the flag is again set to false.
240 private boolean updateMeasurementInterval;
242 public PlugwiseRelayDeviceHandler(Thing thing) {
244 deviceType = getDeviceType();
247 private void calibrate() {
248 sendFastUpdateMessage(new PowerCalibrationRequestMessage(macAddress));
252 public void channelLinked(ChannelUID channelUID) {
253 updateTasks(recurringTasks);
257 public void channelUnlinked(ChannelUID channelUID) {
258 updateTasks(recurringTasks);
261 private void correctPowerState(OnOffType powerState) {
262 if (configuration.getPowerStateChanging() == PowerStateChanging.ALWAYS_OFF && (powerState != OnOffType.OFF)) {
263 logger.debug("Correcting power state of {} ({}) to off", deviceType, macAddress);
264 handleOnOffCommand(OnOffType.OFF);
265 } else if (configuration.getPowerStateChanging() == PowerStateChanging.ALWAYS_ON
266 && (powerState != OnOffType.ON)) {
267 logger.debug("Correcting power state of {} ({}) to on", deviceType, macAddress);
268 handleOnOffCommand(OnOffType.ON);
272 private double correctSign(double value) {
273 return configuration.isSuppliesPower() ? -Math.abs(value) : Math.abs(value);
277 public void dispose() {
278 stopTasks(recurringTasks);
283 protected MACAddress getMACAddress() {
287 private void handleAcknowledgement(AcknowledgementMessage message) {
288 boolean oldConfigurationPending = isConfigurationPending();
290 ExtensionCode extensionCode = message.getExtensionCode();
291 switch (extensionCode) {
293 logger.debug("Received ACK for clock set of {} ({})", deviceType, macAddress);
294 sendMessage(new ClockGetRequestMessage(macAddress));
297 logger.debug("Received ACK for switching on {} ({})", deviceType, macAddress);
298 updateState(CHANNEL_STATE, OnOffType.ON);
301 logger.debug("Received NACK for switching on/off {} ({})", deviceType, macAddress);
304 logger.debug("Received ACK for switching off {} ({})", deviceType, macAddress);
305 updateState(CHANNEL_STATE, OnOffType.OFF);
307 case POWER_LOG_INTERVAL_SET_ACK:
308 logger.debug("Received ACK for power log interval set of {} ({})", deviceType, macAddress);
309 updateMeasurementInterval = false;
311 case REAL_TIME_CLOCK_SET_ACK:
312 logger.debug("Received ACK for setting real-time clock of {} ({})", deviceType, macAddress);
313 sendMessage(new RealTimeClockGetRequestMessage(macAddress));
315 case REAL_TIME_CLOCK_SET_NACK:
316 logger.debug("Received NACK for setting real-time clock of {} ({})", deviceType, macAddress);
319 logger.debug("{} ({}) {} acknowledgement", deviceType, macAddress, extensionCode);
323 boolean newConfigurationPending = isConfigurationPending();
325 if (oldConfigurationPending != newConfigurationPending && !newConfigurationPending) {
326 Configuration newConfiguration = editConfiguration();
327 newConfiguration.put(CONFIG_PROPERTY_UPDATE_CONFIGURATION, false);
328 updateConfiguration(newConfiguration);
331 updateStatusOnDetailChange();
334 private void handleCalibrationResponse(PowerCalibrationResponseMessage message) {
335 boolean wasCalibrated = isCalibrated();
336 calibration = message.getCalibration();
337 logger.debug("{} ({}) calibrated: {}", deviceType, macAddress, calibration);
338 if (!wasCalibrated) {
339 if (isRecentLogAddressKnown()) {
344 sendFastUpdateMessage(new PowerInformationRequestMessage(macAddress));
348 private void handleClockGetResponse(ClockGetResponseMessage message) {
349 updateState(CHANNEL_CLOCK, new StringType(message.getTime()));
353 public void handleCommand(ChannelUID channelUID, Command command) {
354 logger.debug("Handling command '{}' for {} ({}) channel '{}'", command, deviceType, macAddress,
356 if (CHANNEL_STATE.equals(channelUID.getId()) && (command instanceof OnOffType onOffCommand)) {
357 if (configuration.getPowerStateChanging() == PowerStateChanging.COMMAND_SWITCHING) {
358 pendingPowerStateChange = new PendingPowerStateChange(onOffCommand);
359 handleOnOffCommand(onOffCommand);
361 OnOffType onOff = configuration.getPowerStateChanging() == PowerStateChanging.ALWAYS_ON ? OnOffType.ON
363 logger.debug("Ignoring {} ({}) power state change (always {})", deviceType, macAddress, onOff);
364 updateState(CHANNEL_STATE, onOff);
369 private void handleInformationResponse(InformationResponseMessage message) {
370 recentLogAddress = message.getLogAddress();
371 OnOffType powerState = OnOffType.from(message.getPowerState());
373 PendingPowerStateChange change = pendingPowerStateChange;
374 if (change != null) {
375 if (powerState == change.onOff) {
376 pendingPowerStateChange = null;
378 // Power state change message may be lost or the informationUpdateTask may have queried the power
379 // state just before the power state change message arrived
380 if (change.retries < POWER_STATE_RETRIES) {
382 logger.warn("Retrying to switch {} ({}) {} (retry #{})", deviceType, macAddress, change.onOff,
384 handleOnOffCommand(change.onOff);
386 logger.warn("Failed to switch {} ({}) {} after {} retries", deviceType, macAddress, change.onOff,
388 pendingPowerStateChange = null;
393 if (pendingPowerStateChange == null) {
394 updateState(CHANNEL_STATE, powerState);
395 correctPowerState(powerState);
398 if (energy == null && isCalibrated()) {
402 updateProperties(message);
405 private void handleOnOffCommand(OnOffType command) {
406 sendCommandMessage(new PowerChangeRequestMessage(macAddress, command == OnOffType.ON));
407 sendFastUpdateMessage(new InformationRequestMessage(macAddress));
409 // Measurements take 2 seconds to become stable
410 scheduler.schedule(() -> sendFastUpdateMessage(new PowerInformationRequestMessage(macAddress)), 2,
414 private void handlePowerBufferResponse(PowerBufferResponseMessage message) {
415 PowerCalibration localCalibration = calibration;
416 if (localCalibration == null) {
421 Energy mostRecentEnergy = message.getMostRecentDatapoint();
423 if (mostRecentEnergy != null) {
424 // When the current time is '11:44:55.888' and the measurement interval 1 hour, then the end of the most
425 // recent energy measurement interval is at '11:00:00.000'
426 LocalDateTime oneIntervalAgo = LocalDateTime.now().minus(configuration.getMeasurementInterval());
428 boolean isLastInterval = mostRecentEnergy.getEnd().isAfter(oneIntervalAgo);
429 if (isLastInterval) {
430 mostRecentEnergy.setInterval(configuration.getMeasurementInterval());
431 energy = mostRecentEnergy;
432 logger.trace("Updating {} ({}) energy with: {}", deviceType, macAddress, mostRecentEnergy);
433 updateState(CHANNEL_ENERGY,
434 new QuantityType<>(correctSign(mostRecentEnergy.tokWh(localCalibration)), Units.KILOWATT_HOUR));
435 LocalDateTime start = mostRecentEnergy.getStart();
436 updateState(CHANNEL_ENERGY_STAMP,
437 start != null ? PlugwiseUtils.newDateTimeType(start) : UnDefType.NULL);
439 logger.trace("Most recent energy in buffer of {} ({}) is older than one interval ago: {}", deviceType,
440 macAddress, mostRecentEnergy);
443 logger.trace("Most recent energy in buffer of {} ({}) is null", deviceType, macAddress);
447 private void handlePowerInformationResponse(PowerInformationResponseMessage message) {
448 PowerCalibration localCalibration = calibration;
449 if (localCalibration == null) {
454 Energy one = message.getOneSecond();
455 double watt = one.toWatt(localCalibration);
456 if (watt > INVALID_WATT_THRESHOLD) {
457 logger.debug("{} ({}) is in a kind of error state, skipping power information response", deviceType,
462 updateState(CHANNEL_POWER, new QuantityType<>(correctSign(watt), Units.WATT));
465 private void handleRealTimeClockGetResponse(RealTimeClockGetResponseMessage message) {
466 updateState(CHANNEL_REAL_TIME_CLOCK, PlugwiseUtils.newDateTimeType(message.getDateTime()));
470 public void handleResponseMessage(Message message) {
473 switch (message.getType()) {
474 case ACKNOWLEDGEMENT_V1, ACKNOWLEDGEMENT_V2:
475 handleAcknowledgement((AcknowledgementMessage) message);
477 case CLOCK_GET_RESPONSE:
478 handleClockGetResponse(((ClockGetResponseMessage) message));
480 case DEVICE_INFORMATION_RESPONSE:
481 handleInformationResponse((InformationResponseMessage) message);
483 case POWER_BUFFER_RESPONSE:
484 handlePowerBufferResponse((PowerBufferResponseMessage) message);
486 case POWER_CALIBRATION_RESPONSE:
487 handleCalibrationResponse(((PowerCalibrationResponseMessage) message));
489 case POWER_INFORMATION_RESPONSE:
490 handlePowerInformationResponse((PowerInformationResponseMessage) message);
492 case REAL_TIME_CLOCK_GET_RESPONSE:
493 handleRealTimeClockGetResponse((RealTimeClockGetResponseMessage) message);
496 logger.trace("Received unhandled {} message from {} ({})", message.getType(), deviceType, macAddress);
502 public void initialize() {
503 configuration = getConfigAs(PlugwiseRelayConfig.class);
504 macAddress = configuration.getMACAddress();
505 if (!isInitialized()) {
506 setUpdateCommandFlags(null, configuration);
508 if (configuration.isTemporarilyNotInNetwork()) {
509 updateStatus(OFFLINE);
511 updateTasks(recurringTasks);
515 private boolean isCalibrated() {
516 return calibration != null;
520 protected boolean isConfigurationPending() {
521 return updateMeasurementInterval;
524 private boolean isRecentLogAddressKnown() {
525 return recentLogAddress >= 0;
529 protected void sendConfigurationUpdateCommands() {
530 logger.debug("Sending {} ({}) configuration update commands", deviceType, macAddress);
532 if (updateMeasurementInterval) {
533 logger.debug("Sending command to update {} ({}) power log measurement interval", deviceType, macAddress);
534 Duration consumptionInterval = configuration.isSuppliesPower() ? Duration.ZERO
535 : configuration.getMeasurementInterval();
536 Duration productionInterval = configuration.isSuppliesPower() ? configuration.getMeasurementInterval()
539 new PowerLogIntervalSetRequestMessage(macAddress, consumptionInterval, productionInterval));
542 super.sendConfigurationUpdateCommands();
545 private void setUpdateCommandFlags(@Nullable PlugwiseRelayConfig oldConfiguration,
546 PlugwiseRelayConfig newConfiguration) {
547 boolean fullUpdate = newConfiguration.isUpdateConfiguration() && !isConfigurationPending();
549 logger.debug("Updating all configuration properties of {} ({})", deviceType, macAddress);
552 updateMeasurementInterval = fullUpdate || (oldConfiguration != null
553 && (!oldConfiguration.getMeasurementInterval().equals(newConfiguration.getMeasurementInterval())));
554 if (updateMeasurementInterval) {
555 logger.debug("Updating {} ({}) power log interval when online", deviceType, macAddress);
560 protected boolean shouldOnlineTaskBeScheduled() {
561 Bridge bridge = getBridge();
562 return !configuration.isTemporarilyNotInNetwork() && (bridge != null && bridge.getStatus() == ONLINE);
566 protected void updateConfiguration(Configuration configuration) {
567 PlugwiseRelayConfig oldConfiguration = this.configuration;
568 PlugwiseRelayConfig newConfiguration = configuration.as(PlugwiseRelayConfig.class);
570 setUpdateCommandFlags(oldConfiguration, newConfiguration);
572 configuration.put(CONFIG_PROPERTY_UPDATE_CONFIGURATION, isConfigurationPending());
574 super.updateConfiguration(configuration);
577 private void updateEnergy() {
578 int previousLogAddress = recentLogAddress - 1;
579 while (previousLogAddress <= recentLogAddress) {
580 PowerBufferRequestMessage message = new PowerBufferRequestMessage(macAddress, previousLogAddress);
581 previousLogAddress = previousLogAddress + 1;
582 sendMessage(message);
587 protected void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
588 super.updateStatus(status, statusDetail, description);
590 if (status == ONLINE) {
591 if (!isCalibrated()) {
594 if (editProperties().isEmpty()) {
599 updateTasks(recurringTasks);