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.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.LifxProduct.Features;
33 import org.openhab.binding.lifx.internal.dto.AcknowledgementResponse;
34 import org.openhab.binding.lifx.internal.dto.ApplicationRequest;
35 import org.openhab.binding.lifx.internal.dto.Effect;
36 import org.openhab.binding.lifx.internal.dto.GetColorZonesRequest;
37 import org.openhab.binding.lifx.internal.dto.GetHevCycleRequest;
38 import org.openhab.binding.lifx.internal.dto.GetLightInfraredRequest;
39 import org.openhab.binding.lifx.internal.dto.GetLightPowerRequest;
40 import org.openhab.binding.lifx.internal.dto.GetRequest;
41 import org.openhab.binding.lifx.internal.dto.HevCycleState;
42 import org.openhab.binding.lifx.internal.dto.Packet;
43 import org.openhab.binding.lifx.internal.dto.PowerState;
44 import org.openhab.binding.lifx.internal.dto.SetColorRequest;
45 import org.openhab.binding.lifx.internal.dto.SetColorZonesRequest;
46 import org.openhab.binding.lifx.internal.dto.SetHevCycleRequest;
47 import org.openhab.binding.lifx.internal.dto.SetLightInfraredRequest;
48 import org.openhab.binding.lifx.internal.dto.SetLightPowerRequest;
49 import org.openhab.binding.lifx.internal.dto.SetPowerRequest;
50 import org.openhab.binding.lifx.internal.dto.SetTileEffectRequest;
51 import org.openhab.binding.lifx.internal.dto.SignalStrength;
52 import org.openhab.binding.lifx.internal.fields.HSBK;
53 import org.openhab.binding.lifx.internal.listener.LifxLightStateListener;
54 import org.openhab.core.library.types.PercentType;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
59 * The {@link LifxLightStateChanger} listens to state changes of the {@code pendingLightState}. It sends packets to a
60 * light so the change the actual light state to that of the {@code pendingLightState}. When the light does not
61 * acknowledge a packet, it resends it (max 3 times).
63 * @author Wouter Born - Initial contribution
66 public class LifxLightStateChanger implements LifxLightStateListener {
69 * Milliseconds before a packet is considered to be lost (unacknowledged).
71 private static final int PACKET_ACKNOWLEDGE_INTERVAL = 250;
74 * The number of times a lost packet will be resent.
76 private static final int MAX_RETRIES = 3;
78 private final Logger logger = LoggerFactory.getLogger(LifxLightStateChanger.class);
80 private final String logId;
81 private final Features features;
82 private final Duration fadeTime;
83 private final LifxLightState pendingLightState;
84 private final ScheduledExecutorService scheduler;
85 private final LifxLightCommunicationHandler communicationHandler;
87 private final ReentrantLock lock = new ReentrantLock();
89 private @Nullable ScheduledFuture<?> sendJob;
91 private Map<Integer, List<PendingPacket>> pendingPacketsMap = new ConcurrentHashMap<>();
93 private class PendingPacket {
99 private PendingPacket(Packet packet) {
100 this.packet = packet;
103 private boolean hasAcknowledgeIntervalElapsed() {
104 long millisSinceLastSend = System.currentTimeMillis() - lastSend;
105 return millisSinceLastSend > PACKET_ACKNOWLEDGE_INTERVAL;
109 public LifxLightStateChanger(LifxLightContext context, LifxLightCommunicationHandler communicationHandler) {
110 this.logId = context.getLogId();
111 this.features = context.getFeatures();
112 this.fadeTime = context.getConfiguration().getFadeTime();
113 this.pendingLightState = context.getPendingLightState();
114 this.scheduler = context.getScheduler();
115 this.communicationHandler = communicationHandler;
118 private void sendPendingPackets() {
122 removeFailedPackets();
123 PendingPacket pendingPacket = findPacketToSend();
125 if (pendingPacket != null) {
126 Packet packet = pendingPacket.packet;
128 if (pendingPacket.sendCount == 0) {
129 // sendPacket will set the sequence number
130 logger.debug("{} : Sending {} packet", logId, packet.getClass().getSimpleName());
131 communicationHandler.sendPacket(packet);
133 // resendPacket will reuse the sequence number
134 logger.debug("{} : Resending {} packet", logId, packet.getClass().getSimpleName());
135 communicationHandler.resendPacket(packet);
137 pendingPacket.lastSend = System.currentTimeMillis();
138 pendingPacket.sendCount++;
140 } catch (Exception e) {
141 logger.error("Error occurred while sending packet", e);
147 public void start() {
150 communicationHandler.addResponsePacketListener(this::handleResponsePacket);
151 pendingLightState.addListener(this);
152 ScheduledFuture<?> localSendJob = sendJob;
153 if (localSendJob == null || localSendJob.isCancelled()) {
154 sendJob = scheduler.scheduleWithFixedDelay(this::sendPendingPackets, 0, PACKET_INTERVAL,
155 TimeUnit.MILLISECONDS);
157 } catch (Exception e) {
158 logger.error("Error occurred while starting send packets job", e);
167 communicationHandler.removeResponsePacketListener(this::handleResponsePacket);
168 pendingLightState.removeListener(this);
169 ScheduledFuture<?> localSendJob = sendJob;
170 if (localSendJob != null && !localSendJob.isCancelled()) {
171 localSendJob.cancel(true);
174 pendingPacketsMap.clear();
175 } catch (Exception e) {
176 logger.error("Error occurred while stopping send packets job", e);
182 private List<PendingPacket> createPendingPackets(Packet... packets) {
183 Integer packetType = null;
184 List<PendingPacket> pendingPackets = new ArrayList<>();
186 for (Packet packet : packets) {
187 // the acknowledgement is used to resend the packet in case of packet loss
188 packet.setAckRequired(true);
189 // the LIFX LAN protocol spec indicates that the response returned for a request would be the
191 packet.setResponseRequired(false);
192 pendingPackets.add(new PendingPacket(packet));
194 if (packetType == null) {
195 packetType = packet.getPacketType();
196 } else if (packetType != packet.getPacketType()) {
197 throw new IllegalArgumentException("Packets should have same packet type");
201 return pendingPackets;
204 private void addPacketsToMap(Packet... packets) {
205 List<PendingPacket> newPendingPackets = createPendingPackets(packets);
206 int packetType = packets[0].getPacketType();
210 List<PendingPacket> pendingPackets = pendingPacketsMap.get(packetType);
211 if (pendingPackets == null) {
212 pendingPacketsMap.put(packetType, newPendingPackets);
214 pendingPackets.addAll(newPendingPackets);
221 private void replacePacketsInMap(Packet... packets) {
222 List<PendingPacket> pendingPackets = createPendingPackets(packets);
223 int packetType = packets[0].getPacketType();
227 pendingPacketsMap.put(packetType, pendingPackets);
233 private @Nullable PendingPacket findPacketToSend() {
234 PendingPacket result = null;
235 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
236 for (PendingPacket pendingPacket : pendingPackets) {
237 if (pendingPacket.hasAcknowledgeIntervalElapsed()
238 && (result == null || pendingPacket.lastSend < result.lastSend)) {
239 result = pendingPacket;
247 private void removePacketsByType(int packetType) {
250 pendingPacketsMap.remove(packetType);
256 private void removeFailedPackets() {
257 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
258 Iterator<PendingPacket> it = pendingPackets.iterator();
259 while (it.hasNext()) {
260 PendingPacket pendingPacket = it.next();
261 if (pendingPacket.sendCount > MAX_RETRIES && pendingPacket.hasAcknowledgeIntervalElapsed()) {
262 logger.warn("{} failed (unacknowledged {} times to light {})",
263 pendingPacket.packet.getClass().getSimpleName(), pendingPacket.sendCount, logId);
270 private @Nullable PendingPacket removeAcknowledgedPacket(int sequenceNumber) {
271 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
272 Iterator<PendingPacket> it = pendingPackets.iterator();
273 while (it.hasNext()) {
274 PendingPacket pendingPacket = it.next();
275 if (pendingPacket.packet.getSequence() == sequenceNumber) {
277 return pendingPacket;
286 public void handleColorsChange(HSBK[] oldColors, HSBK[] newColors) {
287 if (sameColors(newColors)) {
288 SetColorRequest packet = new SetColorRequest(pendingLightState.getColors()[0], fadeTime.toMillis());
289 removePacketsByType(SetColorZonesRequest.TYPE);
290 replacePacketsInMap(packet);
292 List<SetColorZonesRequest> packets = new ArrayList<>();
293 for (int i = 0; i < newColors.length; i++) {
294 if (newColors[i] != null && !newColors[i].equals(oldColors[i])) {
296 new SetColorZonesRequest(i, newColors[i], fadeTime.toMillis(), ApplicationRequest.APPLY));
299 if (!packets.isEmpty()) {
300 removePacketsByType(SetColorRequest.TYPE);
301 addPacketsToMap(packets.toArray(new SetColorZonesRequest[packets.size()]));
307 public void handlePowerStateChange(@Nullable PowerState oldPowerState, PowerState newPowerState) {
308 if (!newPowerState.equals(oldPowerState)) {
309 SetLightPowerRequest packet = new SetLightPowerRequest(pendingLightState.getPowerState());
310 replacePacketsInMap(packet);
315 public void handleHevCycleStateChange(@Nullable HevCycleState oldHevCycleState, HevCycleState newHevCycleState) {
316 // The change should be ignored when both the old and new state are disabled regardless of the cycle duration
317 if (!newHevCycleState.equals(oldHevCycleState)
318 && (oldHevCycleState == null || oldHevCycleState.isEnable() || newHevCycleState.isEnable())) {
319 SetHevCycleRequest packet = new SetHevCycleRequest(newHevCycleState.isEnable(),
320 newHevCycleState.getDuration());
321 replacePacketsInMap(packet);
326 public void handleInfraredChange(@Nullable PercentType oldInfrared, PercentType newInfrared) {
327 PercentType infrared = pendingLightState.getInfrared();
328 if (infrared != null) {
329 SetLightInfraredRequest packet = new SetLightInfraredRequest(percentTypeToInfrared(infrared));
330 replacePacketsInMap(packet);
335 public void handleSignalStrengthChange(@Nullable SignalStrength oldSignalStrength,
336 SignalStrength newSignalStrength) {
341 public void handleTileEffectChange(@Nullable Effect oldEffect, Effect newEffect) {
342 if (!newEffect.equals(oldEffect)) {
343 SetTileEffectRequest packet = new SetTileEffectRequest(newEffect);
344 replacePacketsInMap(packet);
348 public void handleResponsePacket(Packet packet) {
349 if (packet instanceof AcknowledgementResponse) {
350 long ackTimestamp = System.currentTimeMillis();
352 PendingPacket pendingPacket;
356 pendingPacket = removeAcknowledgedPacket(packet.getSequence());
361 if (pendingPacket != null) {
362 Packet sentPacket = pendingPacket.packet;
363 logger.debug("{} : {} packet was acknowledged in {}ms", logId, sentPacket.getClass().getSimpleName(),
364 ackTimestamp - pendingPacket.lastSend);
366 // when these packets get lost the current state will still be updated by the
367 // LifxLightCurrentStateUpdater
368 if (sentPacket instanceof SetPowerRequest) {
369 GetLightPowerRequest powerPacket = new GetLightPowerRequest();
370 communicationHandler.sendPacket(powerPacket);
371 } else if (sentPacket instanceof SetColorRequest) {
372 GetRequest colorPacket = new GetRequest();
373 communicationHandler.sendPacket(colorPacket);
374 getZonesIfZonesAreSet();
375 } else if (sentPacket instanceof SetColorZonesRequest) {
376 getZonesIfZonesAreSet();
377 } else if (sentPacket instanceof SetHevCycleRequest) {
378 scheduler.schedule(() -> {
379 GetHevCycleRequest hevCyclePacket = new GetHevCycleRequest();
380 communicationHandler.sendPacket(hevCyclePacket);
381 GetLightPowerRequest powerPacket = new GetLightPowerRequest();
382 communicationHandler.sendPacket(powerPacket);
383 }, 600, TimeUnit.MILLISECONDS);
384 } else if (sentPacket instanceof SetLightInfraredRequest) {
385 GetLightInfraredRequest infraredPacket = new GetLightInfraredRequest();
386 communicationHandler.sendPacket(infraredPacket);
389 logger.debug("{} : No pending packet found for ack with sequence number: {}", logId,
390 packet.getSequence());
395 private void getZonesIfZonesAreSet() {
396 if (features.hasFeature(MULTIZONE)) {
397 List<PendingPacket> pending = pendingPacketsMap.get(SetColorZonesRequest.TYPE);
398 if (pending == null || pending.isEmpty()) {
399 GetColorZonesRequest zoneColorPacket = new GetColorZonesRequest();
400 communicationHandler.sendPacket(zoneColorPacket);