2 * Copyright (c) 2010-2021 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.lifx.internal;
15 import static org.openhab.binding.lifx.internal.LifxBindingConstants.PACKET_INTERVAL;
16 import static org.openhab.binding.lifx.internal.LifxProduct.Feature.MULTIZONE;
17 import static org.openhab.binding.lifx.internal.util.LifxMessageUtil.*;
19 import java.time.Duration;
20 import java.util.ArrayList;
21 import java.util.Iterator;
22 import java.util.List;
24 import java.util.concurrent.ConcurrentHashMap;
25 import java.util.concurrent.ScheduledExecutorService;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.locks.ReentrantLock;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.lifx.internal.dto.AcknowledgementResponse;
33 import org.openhab.binding.lifx.internal.dto.ApplicationRequest;
34 import org.openhab.binding.lifx.internal.dto.Effect;
35 import org.openhab.binding.lifx.internal.dto.GetColorZonesRequest;
36 import org.openhab.binding.lifx.internal.dto.GetLightInfraredRequest;
37 import org.openhab.binding.lifx.internal.dto.GetLightPowerRequest;
38 import org.openhab.binding.lifx.internal.dto.GetRequest;
39 import org.openhab.binding.lifx.internal.dto.Packet;
40 import org.openhab.binding.lifx.internal.dto.PowerState;
41 import org.openhab.binding.lifx.internal.dto.SetColorRequest;
42 import org.openhab.binding.lifx.internal.dto.SetColorZonesRequest;
43 import org.openhab.binding.lifx.internal.dto.SetLightInfraredRequest;
44 import org.openhab.binding.lifx.internal.dto.SetLightPowerRequest;
45 import org.openhab.binding.lifx.internal.dto.SetPowerRequest;
46 import org.openhab.binding.lifx.internal.dto.SetTileEffectRequest;
47 import org.openhab.binding.lifx.internal.dto.SignalStrength;
48 import org.openhab.binding.lifx.internal.fields.HSBK;
49 import org.openhab.binding.lifx.internal.listener.LifxLightStateListener;
50 import org.openhab.core.library.types.PercentType;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
55 * The {@link LifxLightStateChanger} listens to state changes of the {@code pendingLightState}. It sends packets to a
56 * light so the change the actual light state to that of the {@code pendingLightState}. When the light does not
57 * acknowledge a packet, it resends it (max 3 times).
59 * @author Wouter Born - Initial contribution
62 public class LifxLightStateChanger implements LifxLightStateListener {
65 * Milliseconds before a packet is considered to be lost (unacknowledged).
67 private static final int PACKET_ACKNOWLEDGE_INTERVAL = 250;
70 * The number of times a lost packet will be resent.
72 private static final int MAX_RETRIES = 3;
74 private final Logger logger = LoggerFactory.getLogger(LifxLightStateChanger.class);
76 private final String logId;
77 private final LifxProduct product;
78 private final Duration fadeTime;
79 private final LifxLightState pendingLightState;
80 private final ScheduledExecutorService scheduler;
81 private final LifxLightCommunicationHandler communicationHandler;
83 private final ReentrantLock lock = new ReentrantLock();
85 private @Nullable ScheduledFuture<?> sendJob;
87 private Map<Integer, List<PendingPacket>> pendingPacketsMap = new ConcurrentHashMap<>();
89 private class PendingPacket {
95 private PendingPacket(Packet packet) {
99 private boolean hasAcknowledgeIntervalElapsed() {
100 long millisSinceLastSend = System.currentTimeMillis() - lastSend;
101 return millisSinceLastSend > PACKET_ACKNOWLEDGE_INTERVAL;
105 public LifxLightStateChanger(LifxLightContext context, LifxLightCommunicationHandler communicationHandler) {
106 this.logId = context.getLogId();
107 this.product = context.getProduct();
108 this.fadeTime = context.getConfiguration().getFadeTime();
109 this.pendingLightState = context.getPendingLightState();
110 this.scheduler = context.getScheduler();
111 this.communicationHandler = communicationHandler;
114 private void sendPendingPackets() {
118 removeFailedPackets();
119 PendingPacket pendingPacket = findPacketToSend();
121 if (pendingPacket != null) {
122 Packet packet = pendingPacket.packet;
124 if (pendingPacket.sendCount == 0) {
125 // sendPacket will set the sequence number
126 logger.debug("{} : Sending {} packet", logId, packet.getClass().getSimpleName());
127 communicationHandler.sendPacket(packet);
129 // resendPacket will reuse the sequence number
130 logger.debug("{} : Resending {} packet", logId, packet.getClass().getSimpleName());
131 communicationHandler.resendPacket(packet);
133 pendingPacket.lastSend = System.currentTimeMillis();
134 pendingPacket.sendCount++;
136 } catch (Exception e) {
137 logger.error("Error occurred while sending packet", e);
143 public void start() {
146 communicationHandler.addResponsePacketListener(this::handleResponsePacket);
147 pendingLightState.addListener(this);
148 ScheduledFuture<?> localSendJob = sendJob;
149 if (localSendJob == null || localSendJob.isCancelled()) {
150 sendJob = scheduler.scheduleWithFixedDelay(this::sendPendingPackets, 0, PACKET_INTERVAL,
151 TimeUnit.MILLISECONDS);
153 } catch (Exception e) {
154 logger.error("Error occurred while starting send packets job", e);
163 communicationHandler.removeResponsePacketListener(this::handleResponsePacket);
164 pendingLightState.removeListener(this);
165 ScheduledFuture<?> localSendJob = sendJob;
166 if (localSendJob != null && !localSendJob.isCancelled()) {
167 localSendJob.cancel(true);
170 pendingPacketsMap.clear();
171 } catch (Exception e) {
172 logger.error("Error occurred while stopping send packets job", e);
178 private List<PendingPacket> createPendingPackets(Packet... packets) {
179 Integer packetType = null;
180 List<PendingPacket> pendingPackets = new ArrayList<>();
182 for (Packet packet : packets) {
183 // the acknowledgement is used to resend the packet in case of packet loss
184 packet.setAckRequired(true);
185 // the LIFX LAN protocol spec indicates that the response returned for a request would be the
187 packet.setResponseRequired(false);
188 pendingPackets.add(new PendingPacket(packet));
190 if (packetType == null) {
191 packetType = packet.getPacketType();
192 } else if (packetType != packet.getPacketType()) {
193 throw new IllegalArgumentException("Packets should have same packet type");
197 return pendingPackets;
200 private void addPacketsToMap(Packet... packets) {
201 List<PendingPacket> newPendingPackets = createPendingPackets(packets);
202 int packetType = packets[0].getPacketType();
206 List<PendingPacket> pendingPackets = pendingPacketsMap.get(packetType);
207 if (pendingPackets == null) {
208 pendingPacketsMap.put(packetType, newPendingPackets);
210 pendingPackets.addAll(newPendingPackets);
217 private void replacePacketsInMap(Packet... packets) {
218 List<PendingPacket> pendingPackets = createPendingPackets(packets);
219 int packetType = packets[0].getPacketType();
223 pendingPacketsMap.put(packetType, pendingPackets);
229 private @Nullable PendingPacket findPacketToSend() {
230 PendingPacket result = null;
231 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
232 for (PendingPacket pendingPacket : pendingPackets) {
233 if (pendingPacket.hasAcknowledgeIntervalElapsed()
234 && (result == null || pendingPacket.lastSend < result.lastSend)) {
235 result = pendingPacket;
243 private void removePacketsByType(int packetType) {
246 pendingPacketsMap.remove(packetType);
252 private void removeFailedPackets() {
253 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
254 Iterator<PendingPacket> it = pendingPackets.iterator();
255 while (it.hasNext()) {
256 PendingPacket pendingPacket = it.next();
257 if (pendingPacket.sendCount > MAX_RETRIES && pendingPacket.hasAcknowledgeIntervalElapsed()) {
258 logger.warn("{} failed (unacknowledged {} times to light {})",
259 pendingPacket.packet.getClass().getSimpleName(), pendingPacket.sendCount, logId);
266 private @Nullable PendingPacket removeAcknowledgedPacket(int sequenceNumber) {
267 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
268 Iterator<PendingPacket> it = pendingPackets.iterator();
269 while (it.hasNext()) {
270 PendingPacket pendingPacket = it.next();
271 if (pendingPacket.packet.getSequence() == sequenceNumber) {
273 return pendingPacket;
282 public void handleColorsChange(HSBK[] oldColors, HSBK[] newColors) {
283 if (sameColors(newColors)) {
284 SetColorRequest packet = new SetColorRequest(pendingLightState.getColors()[0], fadeTime.toMillis());
285 removePacketsByType(SetColorZonesRequest.TYPE);
286 replacePacketsInMap(packet);
288 List<SetColorZonesRequest> packets = new ArrayList<>();
289 for (int i = 0; i < newColors.length; i++) {
290 if (newColors[i] != null && !newColors[i].equals(oldColors[i])) {
292 new SetColorZonesRequest(i, newColors[i], fadeTime.toMillis(), ApplicationRequest.APPLY));
295 if (!packets.isEmpty()) {
296 removePacketsByType(SetColorRequest.TYPE);
297 addPacketsToMap(packets.toArray(new SetColorZonesRequest[packets.size()]));
303 public void handlePowerStateChange(@Nullable PowerState oldPowerState, PowerState newPowerState) {
304 if (!newPowerState.equals(oldPowerState)) {
305 SetLightPowerRequest packet = new SetLightPowerRequest(pendingLightState.getPowerState());
306 replacePacketsInMap(packet);
311 public void handleInfraredChange(@Nullable PercentType oldInfrared, PercentType newInfrared) {
312 PercentType infrared = pendingLightState.getInfrared();
313 if (infrared != null) {
314 SetLightInfraredRequest packet = new SetLightInfraredRequest(percentTypeToInfrared(infrared));
315 replacePacketsInMap(packet);
320 public void handleSignalStrengthChange(@Nullable SignalStrength oldSignalStrength,
321 SignalStrength newSignalStrength) {
326 public void handleTileEffectChange(@Nullable Effect oldEffect, Effect newEffect) {
327 if (oldEffect == null || !oldEffect.equals(newEffect)) {
328 SetTileEffectRequest packet = new SetTileEffectRequest(newEffect);
329 replacePacketsInMap(packet);
333 public void handleResponsePacket(Packet packet) {
334 if (packet instanceof AcknowledgementResponse) {
335 long ackTimestamp = System.currentTimeMillis();
337 PendingPacket pendingPacket;
341 pendingPacket = removeAcknowledgedPacket(packet.getSequence());
346 if (pendingPacket != null) {
347 Packet sentPacket = pendingPacket.packet;
348 logger.debug("{} : {} packet was acknowledged in {}ms", logId, sentPacket.getClass().getSimpleName(),
349 ackTimestamp - pendingPacket.lastSend);
351 // when these packets get lost the current state will still be updated by the
352 // LifxLightCurrentStateUpdater
353 if (sentPacket instanceof SetPowerRequest) {
354 GetLightPowerRequest powerPacket = new GetLightPowerRequest();
355 communicationHandler.sendPacket(powerPacket);
356 } else if (sentPacket instanceof SetColorRequest) {
357 GetRequest colorPacket = new GetRequest();
358 communicationHandler.sendPacket(colorPacket);
359 getZonesIfZonesAreSet();
360 } else if (sentPacket instanceof SetColorZonesRequest) {
361 getZonesIfZonesAreSet();
362 } else if (sentPacket instanceof SetLightInfraredRequest) {
363 GetLightInfraredRequest infraredPacket = new GetLightInfraredRequest();
364 communicationHandler.sendPacket(infraredPacket);
367 logger.debug("{} : No pending packet found for ack with sequence number: {}", logId,
368 packet.getSequence());
373 private void getZonesIfZonesAreSet() {
374 if (product.hasFeature(MULTIZONE)) {
375 List<PendingPacket> pending = pendingPacketsMap.get(SetColorZonesRequest.TYPE);
376 if (pending == null || pending.isEmpty()) {
377 GetColorZonesRequest zoneColorPacket = new GetColorZonesRequest();
378 communicationHandler.sendPacket(zoneColorPacket);