/** * Copyright (c) 2014-2017 by the respective copyright holders. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html */ package org.eclipse.smarthome.binding.lifx.internal; import static org.eclipse.smarthome.binding.lifx.LifxBindingConstants.PACKET_INTERVAL; import static org.eclipse.smarthome.binding.lifx.internal.LifxUtils.*; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import org.eclipse.smarthome.binding.lifx.LifxBindingConstants; import org.eclipse.smarthome.binding.lifx.internal.fields.HSBK; import org.eclipse.smarthome.binding.lifx.internal.fields.MACAddress; import org.eclipse.smarthome.binding.lifx.internal.listener.LifxLightStateListener; import org.eclipse.smarthome.binding.lifx.internal.listener.LifxResponsePacketListener; import org.eclipse.smarthome.binding.lifx.internal.protocol.AcknowledgementResponse; import org.eclipse.smarthome.binding.lifx.internal.protocol.ApplicationRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.GetColorZonesRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.GetLightInfraredRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.GetLightPowerRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.GetRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.Packet; import org.eclipse.smarthome.binding.lifx.internal.protocol.PowerState; import org.eclipse.smarthome.binding.lifx.internal.protocol.Products; import org.eclipse.smarthome.binding.lifx.internal.protocol.SetColorRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.SetColorZonesRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.SetLightInfraredRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.SetLightPowerRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.SetPowerRequest; import org.eclipse.smarthome.binding.lifx.internal.protocol.SignalStrength; import org.eclipse.smarthome.core.common.ThreadPoolManager; import org.eclipse.smarthome.core.library.types.PercentType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * The {@link LifxLightStateChanger} listens to state changes of the {@code pendingLightState}. It sends packets to a * light so the change the actual light state to that of the {@code pendingLightState}. When the light does not * acknowledge a packet, it resends it (max 3 times). * * @author Wouter Born - Extracted class from LifxLightHandler, added logic for handling packet loss */ public class LifxLightStateChanger implements LifxLightStateListener, LifxResponsePacketListener { /** * Milliseconds before a packet is considered to be lost (unacknowledged). */ private static final int PACKET_ACKNOWLEDGE_INTERVAL = 250; /** * The number of times a lost packet will be resent. */ private static final int MAX_RETRIES = 3; private final Logger logger = LoggerFactory.getLogger(LifxLightStateChanger.class); private final String macAsHex; private final LifxLightState pendingLightState; private final LifxLightCommunicationHandler communicationHandler; private final long fadeTime; private final Products product; private final ReentrantLock lock = new ReentrantLock(); private ScheduledExecutorService scheduler = ThreadPoolManager .getScheduledPool(LifxBindingConstants.THREADPOOL_NAME); private ScheduledFuture<?> sendJob; private Map<Integer, List<PendingPacket>> pendingPacketsMap = new ConcurrentHashMap<>(); private class PendingPacket { long lastSend; int sendCount; final Packet packet; private PendingPacket(Packet packet) { this.packet = packet; } private boolean hasAcknowledgeIntervalElapsed() { long millisSinceLastSend = System.currentTimeMillis() - lastSend; return millisSinceLastSend > PACKET_ACKNOWLEDGE_INTERVAL; } } private Runnable sendRunnable = new Runnable() { @Override public void run() { try { lock.lock(); removeFailedPackets(); PendingPacket pendingPacket = findPacketToSend(); if (pendingPacket != null) { Packet packet = pendingPacket.packet; if (pendingPacket.sendCount == 0) { // sendPacket will set the sequence number logger.debug("{} : Sending {} packet", macAsHex, packet.getClass().getSimpleName()); communicationHandler.sendPacket(packet); } else { // resendPacket will reuse the sequence number logger.debug("{} : Resending {} packet", macAsHex, packet.getClass().getSimpleName()); communicationHandler.resendPacket(packet); } pendingPacket.lastSend = System.currentTimeMillis(); pendingPacket.sendCount++; } } catch (Exception e) { logger.error("Error occurred while sending packet", e); } finally { lock.unlock(); } } }; public LifxLightStateChanger(MACAddress macAddress, LifxLightState pendingLightState, LifxLightCommunicationHandler communicationHandler, Products product, long fadeTime) { this.macAsHex = macAddress.getHex(); this.pendingLightState = pendingLightState; this.communicationHandler = communicationHandler; this.product = product; this.fadeTime = fadeTime; } public void start() { try { lock.lock(); communicationHandler.addResponsePacketListener(this); pendingLightState.addListener(this); if (sendJob == null || sendJob.isCancelled()) { sendJob = scheduler.scheduleWithFixedDelay(sendRunnable, 0, PACKET_INTERVAL, TimeUnit.MILLISECONDS); } } catch (Exception e) { logger.error("Error occurred while starting send packets job", e); } finally { lock.unlock(); } } public void stop() { try { lock.lock(); communicationHandler.removeResponsePacketListener(this); pendingLightState.removeListener(this); if (sendJob != null && !sendJob.isCancelled()) { sendJob.cancel(true); sendJob = null; } pendingPacketsMap.clear(); } catch (Exception e) { logger.error("Error occurred while stopping send packets job", e); } finally { lock.unlock(); } } private List<PendingPacket> createPendingPackets(Packet... packets) { Integer packetType = null; List<PendingPacket> pendingPackets = new ArrayList<>(); for (Packet packet : packets) { // the acknowledgement is used to resend the packet in case of packet loss packet.setAckRequired(true); // the LIFX LAN protocol spec indicates that the response returned for a request would be the // previous value packet.setResponseRequired(false); pendingPackets.add(new PendingPacket(packet)); if (packetType == null) { packetType = packet.getPacketType(); } else if (packetType != packet.getPacketType()) { throw new RuntimeException("Packets should have same packet type"); } } return pendingPackets; } private void addPacketsToMap(Packet... packets) { List<PendingPacket> pendingPackets = createPendingPackets(packets); int packetType = packets[0].getPacketType(); try { lock.lock(); if (pendingPacketsMap.get(packetType) == null) { pendingPacketsMap.put(packetType, pendingPackets); } else { pendingPacketsMap.get(packetType).addAll(pendingPackets); } } finally { lock.unlock(); } } private void replacePacketsInMap(Packet... packets) { List<PendingPacket> pendingPackets = createPendingPackets(packets); int packetType = packets[0].getPacketType(); try { lock.lock(); pendingPacketsMap.put(packetType, pendingPackets); } finally { lock.unlock(); } } private PendingPacket findPacketToSend() { PendingPacket result = null; for (List<PendingPacket> pendingPackets : pendingPacketsMap.values()) { for (PendingPacket pendingPacket : pendingPackets) { if (pendingPacket.hasAcknowledgeIntervalElapsed() && (result == null || pendingPacket.lastSend < result.lastSend)) { result = pendingPacket; } } } return result; } private void removePacketsByType(int packetType) { try { lock.lock(); pendingPacketsMap.remove(packetType); } finally { lock.unlock(); } } private void removeFailedPackets() { for (Integer key : pendingPacketsMap.keySet()) { List<PendingPacket> pendingPackets = pendingPacketsMap.get(key); Iterator<PendingPacket> it = pendingPackets.iterator(); while (it.hasNext()) { PendingPacket pendingPacket = it.next(); if (pendingPacket.sendCount > MAX_RETRIES && pendingPacket.hasAcknowledgeIntervalElapsed()) { logger.warn("{} failed (unacknowledged {} times)", pendingPacket.packet.getClass().getSimpleName(), pendingPacket.sendCount); it.remove(); } } } } private PendingPacket removeAcknowledgedPacket(int sequenceNumber) { for (Integer key : pendingPacketsMap.keySet()) { List<PendingPacket> pendingPackets = pendingPacketsMap.get(key); Iterator<PendingPacket> it = pendingPackets.iterator(); while (it.hasNext()) { PendingPacket pendingPacket = it.next(); if (pendingPacket.packet.getSequence() == sequenceNumber) { it.remove(); return pendingPacket; } } } return null; } @Override public void handleColorsChange(HSBK[] oldColors, HSBK[] newColors) { if (sameColors(newColors)) { SetColorRequest packet = new SetColorRequest(pendingLightState.getColors()[0], fadeTime); removePacketsByType(SetColorZonesRequest.TYPE); replacePacketsInMap(packet); } else { List<SetColorZonesRequest> packets = new ArrayList<>(); for (int i = 0; i < newColors.length; i++) { if (newColors[i] != null && !newColors[i].equals(oldColors[i])) { packets.add(new SetColorZonesRequest(i, newColors[i], fadeTime, ApplicationRequest.APPLY)); } } if (!packets.isEmpty()) { removePacketsByType(SetColorRequest.TYPE); addPacketsToMap(packets.toArray(new SetColorZonesRequest[packets.size()])); } } } @Override public void handlePowerStateChange(PowerState oldPowerState, PowerState newPowerState) { if (newPowerState != null && !newPowerState.equals(oldPowerState)) { SetLightPowerRequest packet = new SetLightPowerRequest(pendingLightState.getPowerState()); replacePacketsInMap(packet); } } @Override public void handleInfraredChange(PercentType oldInfrared, PercentType newInfrared) { int infrared = percentTypeToInfrared(pendingLightState.getInfrared()); SetLightInfraredRequest packet = new SetLightInfraredRequest(infrared); replacePacketsInMap(packet); } @Override public void handleSignalStrengthChange(SignalStrength oldSignalStrength, SignalStrength newSignalStrength) { // Nothing to handle } @Override public void handleResponsePacket(Packet packet) { if (packet instanceof AcknowledgementResponse) { long ackTimestamp = System.currentTimeMillis(); PendingPacket pendingPacket; try { lock.lock(); pendingPacket = removeAcknowledgedPacket(packet.getSequence()); } finally { lock.unlock(); } if (pendingPacket != null) { Packet sentPacket = pendingPacket.packet; logger.debug("{} : {} packet was acknowledged in {}ms", macAsHex, sentPacket.getClass().getSimpleName(), ackTimestamp - pendingPacket.lastSend); // when these packets get lost the current state will still be updated by the // LifxLightCurrentStateUpdater if (sentPacket instanceof SetPowerRequest) { GetLightPowerRequest powerPacket = new GetLightPowerRequest(); communicationHandler.sendPacket(powerPacket); } else if (sentPacket instanceof SetColorRequest) { GetRequest colorPacket = new GetRequest(); communicationHandler.sendPacket(colorPacket); getZonesIfZonesAreSet(); } else if (sentPacket instanceof SetColorZonesRequest) { getZonesIfZonesAreSet(); } else if (sentPacket instanceof SetLightInfraredRequest) { GetLightInfraredRequest infraredPacket = new GetLightInfraredRequest(); communicationHandler.sendPacket(infraredPacket); } } else { logger.debug("{} : No pending packet found for ack with sequence number: {}", macAsHex, packet.getSequence()); } } } private void getZonesIfZonesAreSet() { if (product.isMultiZone()) { List<PendingPacket> pending = pendingPacketsMap.get(SetColorZonesRequest.TYPE); if (pending == null || pending.isEmpty()) { GetColorZonesRequest zoneColorPacket = new GetColorZonesRequest(); communicationHandler.sendPacket(zoneColorPacket); } } } }