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.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.GetLightInfraredRequest;
38 import org.openhab.binding.lifx.internal.dto.GetLightPowerRequest;
39 import org.openhab.binding.lifx.internal.dto.GetRequest;
40 import org.openhab.binding.lifx.internal.dto.Packet;
41 import org.openhab.binding.lifx.internal.dto.PowerState;
42 import org.openhab.binding.lifx.internal.dto.SetColorRequest;
43 import org.openhab.binding.lifx.internal.dto.SetColorZonesRequest;
44 import org.openhab.binding.lifx.internal.dto.SetLightInfraredRequest;
45 import org.openhab.binding.lifx.internal.dto.SetLightPowerRequest;
46 import org.openhab.binding.lifx.internal.dto.SetPowerRequest;
47 import org.openhab.binding.lifx.internal.dto.SetTileEffectRequest;
48 import org.openhab.binding.lifx.internal.dto.SignalStrength;
49 import org.openhab.binding.lifx.internal.fields.HSBK;
50 import org.openhab.binding.lifx.internal.listener.LifxLightStateListener;
51 import org.openhab.core.library.types.PercentType;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
56 * The {@link LifxLightStateChanger} listens to state changes of the {@code pendingLightState}. It sends packets to a
57 * light so the change the actual light state to that of the {@code pendingLightState}. When the light does not
58 * acknowledge a packet, it resends it (max 3 times).
60 * @author Wouter Born - Initial contribution
63 public class LifxLightStateChanger implements LifxLightStateListener {
66 * Milliseconds before a packet is considered to be lost (unacknowledged).
68 private static final int PACKET_ACKNOWLEDGE_INTERVAL = 250;
71 * The number of times a lost packet will be resent.
73 private static final int MAX_RETRIES = 3;
75 private final Logger logger = LoggerFactory.getLogger(LifxLightStateChanger.class);
77 private final String logId;
78 private final Features features;
79 private final Duration fadeTime;
80 private final LifxLightState pendingLightState;
81 private final ScheduledExecutorService scheduler;
82 private final LifxLightCommunicationHandler communicationHandler;
84 private final ReentrantLock lock = new ReentrantLock();
86 private @Nullable ScheduledFuture<?> sendJob;
88 private Map<Integer, List<PendingPacket>> pendingPacketsMap = new ConcurrentHashMap<>();
90 private class PendingPacket {
96 private PendingPacket(Packet packet) {
100 private boolean hasAcknowledgeIntervalElapsed() {
101 long millisSinceLastSend = System.currentTimeMillis() - lastSend;
102 return millisSinceLastSend > PACKET_ACKNOWLEDGE_INTERVAL;
106 public LifxLightStateChanger(LifxLightContext context, LifxLightCommunicationHandler communicationHandler) {
107 this.logId = context.getLogId();
108 this.features = context.getFeatures();
109 this.fadeTime = context.getConfiguration().getFadeTime();
110 this.pendingLightState = context.getPendingLightState();
111 this.scheduler = context.getScheduler();
112 this.communicationHandler = communicationHandler;
115 private void sendPendingPackets() {
119 removeFailedPackets();
120 PendingPacket pendingPacket = findPacketToSend();
122 if (pendingPacket != null) {
123 Packet packet = pendingPacket.packet;
125 if (pendingPacket.sendCount == 0) {
126 // sendPacket will set the sequence number
127 logger.debug("{} : Sending {} packet", logId, packet.getClass().getSimpleName());
128 communicationHandler.sendPacket(packet);
130 // resendPacket will reuse the sequence number
131 logger.debug("{} : Resending {} packet", logId, packet.getClass().getSimpleName());
132 communicationHandler.resendPacket(packet);
134 pendingPacket.lastSend = System.currentTimeMillis();
135 pendingPacket.sendCount++;
137 } catch (Exception e) {
138 logger.error("Error occurred while sending packet", e);
144 public void start() {
147 communicationHandler.addResponsePacketListener(this::handleResponsePacket);
148 pendingLightState.addListener(this);
149 ScheduledFuture<?> localSendJob = sendJob;
150 if (localSendJob == null || localSendJob.isCancelled()) {
151 sendJob = scheduler.scheduleWithFixedDelay(this::sendPendingPackets, 0, PACKET_INTERVAL,
152 TimeUnit.MILLISECONDS);
154 } catch (Exception e) {
155 logger.error("Error occurred while starting send packets job", e);
164 communicationHandler.removeResponsePacketListener(this::handleResponsePacket);
165 pendingLightState.removeListener(this);
166 ScheduledFuture<?> localSendJob = sendJob;
167 if (localSendJob != null && !localSendJob.isCancelled()) {
168 localSendJob.cancel(true);
171 pendingPacketsMap.clear();
172 } catch (Exception e) {
173 logger.error("Error occurred while stopping send packets job", e);
179 private List<PendingPacket> createPendingPackets(Packet... packets) {
180 Integer packetType = null;
181 List<PendingPacket> pendingPackets = new ArrayList<>();
183 for (Packet packet : packets) {
184 // the acknowledgement is used to resend the packet in case of packet loss
185 packet.setAckRequired(true);
186 // the LIFX LAN protocol spec indicates that the response returned for a request would be the
188 packet.setResponseRequired(false);
189 pendingPackets.add(new PendingPacket(packet));
191 if (packetType == null) {
192 packetType = packet.getPacketType();
193 } else if (packetType != packet.getPacketType()) {
194 throw new IllegalArgumentException("Packets should have same packet type");
198 return pendingPackets;
201 private void addPacketsToMap(Packet... packets) {
202 List<PendingPacket> newPendingPackets = createPendingPackets(packets);
203 int packetType = packets[0].getPacketType();
207 List<PendingPacket> pendingPackets = pendingPacketsMap.get(packetType);
208 if (pendingPackets == null) {
209 pendingPacketsMap.put(packetType, newPendingPackets);
211 pendingPackets.addAll(newPendingPackets);
218 private void replacePacketsInMap(Packet... packets) {
219 List<PendingPacket> pendingPackets = createPendingPackets(packets);
220 int packetType = packets[0].getPacketType();
224 pendingPacketsMap.put(packetType, pendingPackets);
230 private @Nullable PendingPacket findPacketToSend() {
231 PendingPacket result = null;
232 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
233 for (PendingPacket pendingPacket : pendingPackets) {
234 if (pendingPacket.hasAcknowledgeIntervalElapsed()
235 && (result == null || pendingPacket.lastSend < result.lastSend)) {
236 result = pendingPacket;
244 private void removePacketsByType(int packetType) {
247 pendingPacketsMap.remove(packetType);
253 private void removeFailedPackets() {
254 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
255 Iterator<PendingPacket> it = pendingPackets.iterator();
256 while (it.hasNext()) {
257 PendingPacket pendingPacket = it.next();
258 if (pendingPacket.sendCount > MAX_RETRIES && pendingPacket.hasAcknowledgeIntervalElapsed()) {
259 logger.warn("{} failed (unacknowledged {} times to light {})",
260 pendingPacket.packet.getClass().getSimpleName(), pendingPacket.sendCount, logId);
267 private @Nullable PendingPacket removeAcknowledgedPacket(int sequenceNumber) {
268 for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) {
269 Iterator<PendingPacket> it = pendingPackets.iterator();
270 while (it.hasNext()) {
271 PendingPacket pendingPacket = it.next();
272 if (pendingPacket.packet.getSequence() == sequenceNumber) {
274 return pendingPacket;
283 public void handleColorsChange(HSBK[] oldColors, HSBK[] newColors) {
284 if (sameColors(newColors)) {
285 SetColorRequest packet = new SetColorRequest(pendingLightState.getColors()[0], fadeTime.toMillis());
286 removePacketsByType(SetColorZonesRequest.TYPE);
287 replacePacketsInMap(packet);
289 List<SetColorZonesRequest> packets = new ArrayList<>();
290 for (int i = 0; i < newColors.length; i++) {
291 if (newColors[i] != null && !newColors[i].equals(oldColors[i])) {
293 new SetColorZonesRequest(i, newColors[i], fadeTime.toMillis(), ApplicationRequest.APPLY));
296 if (!packets.isEmpty()) {
297 removePacketsByType(SetColorRequest.TYPE);
298 addPacketsToMap(packets.toArray(new SetColorZonesRequest[packets.size()]));
304 public void handlePowerStateChange(@Nullable PowerState oldPowerState, PowerState newPowerState) {
305 if (!newPowerState.equals(oldPowerState)) {
306 SetLightPowerRequest packet = new SetLightPowerRequest(pendingLightState.getPowerState());
307 replacePacketsInMap(packet);
312 public void handleInfraredChange(@Nullable PercentType oldInfrared, PercentType newInfrared) {
313 PercentType infrared = pendingLightState.getInfrared();
314 if (infrared != null) {
315 SetLightInfraredRequest packet = new SetLightInfraredRequest(percentTypeToInfrared(infrared));
316 replacePacketsInMap(packet);
321 public void handleSignalStrengthChange(@Nullable SignalStrength oldSignalStrength,
322 SignalStrength newSignalStrength) {
327 public void handleTileEffectChange(@Nullable Effect oldEffect, Effect newEffect) {
328 if (oldEffect == null || !oldEffect.equals(newEffect)) {
329 SetTileEffectRequest packet = new SetTileEffectRequest(newEffect);
330 replacePacketsInMap(packet);
334 public void handleResponsePacket(Packet packet) {
335 if (packet instanceof AcknowledgementResponse) {
336 long ackTimestamp = System.currentTimeMillis();
338 PendingPacket pendingPacket;
342 pendingPacket = removeAcknowledgedPacket(packet.getSequence());
347 if (pendingPacket != null) {
348 Packet sentPacket = pendingPacket.packet;
349 logger.debug("{} : {} packet was acknowledged in {}ms", logId, sentPacket.getClass().getSimpleName(),
350 ackTimestamp - pendingPacket.lastSend);
352 // when these packets get lost the current state will still be updated by the
353 // LifxLightCurrentStateUpdater
354 if (sentPacket instanceof SetPowerRequest) {
355 GetLightPowerRequest powerPacket = new GetLightPowerRequest();
356 communicationHandler.sendPacket(powerPacket);
357 } else if (sentPacket instanceof SetColorRequest) {
358 GetRequest colorPacket = new GetRequest();
359 communicationHandler.sendPacket(colorPacket);
360 getZonesIfZonesAreSet();
361 } else if (sentPacket instanceof SetColorZonesRequest) {
362 getZonesIfZonesAreSet();
363 } else if (sentPacket instanceof SetLightInfraredRequest) {
364 GetLightInfraredRequest infraredPacket = new GetLightInfraredRequest();
365 communicationHandler.sendPacket(infraredPacket);
368 logger.debug("{} : No pending packet found for ack with sequence number: {}", logId,
369 packet.getSequence());
374 private void getZonesIfZonesAreSet() {
375 if (features.hasFeature(MULTIZONE)) {
376 List<PendingPacket> pending = pendingPacketsMap.get(SetColorZonesRequest.TYPE);
377 if (pending == null || pending.isEmpty()) {
378 GetColorZonesRequest zoneColorPacket = new GetColorZonesRequest();
379 communicationHandler.sendPacket(zoneColorPacket);