/** * Copyright (c) 2010-2016 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.openhab.binding.maxcul.internal; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ConcurrentHashMap; import org.openhab.binding.maxcul.MaxCulBindingProvider; import org.openhab.binding.maxcul.internal.message.sequencers.MessageSequencer; import org.openhab.binding.maxcul.internal.messages.AckMsg; import org.openhab.binding.maxcul.internal.messages.AddLinkPartnerMsg; import org.openhab.binding.maxcul.internal.messages.BaseMsg; import org.openhab.binding.maxcul.internal.messages.ConfigTemperaturesMsg; import org.openhab.binding.maxcul.internal.messages.MaxCulBindingMessageProcessor; import org.openhab.binding.maxcul.internal.messages.MaxCulMsgType; import org.openhab.binding.maxcul.internal.messages.PairPingMsg; import org.openhab.binding.maxcul.internal.messages.PairPongMsg; import org.openhab.binding.maxcul.internal.messages.ResetMsg; import org.openhab.binding.maxcul.internal.messages.SetDisplayActualTempMsg; import org.openhab.binding.maxcul.internal.messages.SetGroupIdMsg; import org.openhab.binding.maxcul.internal.messages.SetTemperatureMsg; import org.openhab.binding.maxcul.internal.messages.ThermostatControlMode; import org.openhab.binding.maxcul.internal.messages.ThermostatStateMsg; import org.openhab.binding.maxcul.internal.messages.TimeInfoMsg; import org.openhab.binding.maxcul.internal.messages.WakeupMsg; import org.openhab.binding.maxcul.internal.messages.WallThermostatControlMsg; import org.openhab.binding.maxcul.internal.messages.WallThermostatStateMsg; import org.openhab.io.transport.cul.CULCommunicationException; import org.openhab.io.transport.cul.CULHandler; import org.openhab.io.transport.cul.CULListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Handle messages going to and from the CUL device. Make sure to intercept * control command responses first before passing on valid MAX! messages to the * binding itself for processing. * * @author Paul Hampson (cyclingengineer) * @since 1.6.0 */ public class MaxCulMsgHandler implements CULListener { private static final Logger logger = LoggerFactory.getLogger(MaxCulMsgHandler.class); class SenderQueueItem { BaseMsg msg; Date expiry; int retryCount = 0; } private Date lastTransmit = new Date(); private Date endOfQueueTransmit; private int msgCount = 0; private CULHandler cul = null; private String srcAddr; private HashMap<Byte, MessageSequencer> sequenceRegister; private LinkedList<SenderQueueItem> sendQueue; private ConcurrentHashMap<Byte, SenderQueueItem> pendingAckQueue; private MaxCulBindingMessageProcessor mcbmp = null; private Map<SenderQueueItem, Timer> sendQueueTimers = new HashMap<SenderQueueItem, Timer>(); private Collection<MaxCulBindingProvider> providers; private boolean listenMode = false; private final int MESSAGE_EXPIRY_PERIOD = 10000; public MaxCulMsgHandler(String srcAddr, CULHandler cul, Collection<MaxCulBindingProvider> providers) { this.cul = cul; cul.registerListener(this); this.srcAddr = srcAddr; this.sequenceRegister = new HashMap<Byte, MessageSequencer>(); this.sendQueue = new LinkedList<SenderQueueItem>(); this.pendingAckQueue = new ConcurrentHashMap<Byte, SenderQueueItem>(); this.lastTransmit = new Date(); /* init as now */ this.endOfQueueTransmit = this.lastTransmit; this.providers = providers; } private byte getMessageCount() { this.msgCount += 1; this.msgCount &= 0xFF; return (byte) this.msgCount; } private boolean enoughCredit(int requiredCredit, boolean fastSend) { int availableCredit = cul.getCredit10ms(); int preambleCredit = fastSend ? 0 : 100; boolean result = (availableCredit >= (requiredCredit + preambleCredit)); logger.debug("Fast Send? " + fastSend + ", preambleCredit = " + preambleCredit + ", requiredCredit = " + requiredCredit + ", availableCredit = " + availableCredit + ", enoughCredit? " + result); return result; } private void transmitMessage(BaseMsg data, SenderQueueItem queueItem) { try { cul.send(data.rawMsg); } catch (CULCommunicationException e) { logger.error("Unable to send CUL message " + data + " because: " + e.getMessage()); } /* update surplus credit value */ boolean fastSend = false; if (data.isPartOfSequence()) { fastSend = data.getMessageSequencer().useFastSend(); } enoughCredit(data.requiredCredit(), fastSend); this.lastTransmit = new Date(); if (this.endOfQueueTransmit.before(this.lastTransmit)) { /* hit a time after the queue finished tx'ing */ this.endOfQueueTransmit = this.lastTransmit; } if (data.msgType != MaxCulMsgType.ACK) { /* awaiting ack now */ SenderQueueItem qi = queueItem; if (qi == null) { qi = new SenderQueueItem(); qi.msg = data; } qi.expiry = new Date(this.lastTransmit.getTime() + MESSAGE_EXPIRY_PERIOD); this.pendingAckQueue.put(qi.msg.msgCount, qi); /* schedule a check of pending acks */ TimerTask ackCheckTask = new TimerTask() { @Override public void run() { checkPendingAcks(); } }; Timer timer = new Timer(); timer.schedule(ackCheckTask, qi.expiry); } } public void sendMessage(BaseMsg msg) { sendMessage(msg, null); } /** * Send a raw Base Message * * @param msg * Base message to send * @param queueItem * queue item (used for retransmission) */ private void sendMessage(BaseMsg msg, SenderQueueItem queueItem) { Timer timer = null; if (msg.readyToSend()) { if (enoughCredit(msg.requiredCredit(), msg.isFastSend()) && this.sendQueue.isEmpty()) { /* * send message as we have enough credit and nothing is on the * queue waiting */ logger.debug("Sending message immediately. Message is " + msg.msgType + " => " + msg.rawMsg); transmitMessage(msg, queueItem); logger.debug("Credit required " + msg.requiredCredit()); } else { /* * message is going on the queue - this means that the device * may well go to standby before it receives it so change into * long slow send format with big preamble */ msg.setFastSend(false); /* * don't have enough credit or there are messages ahead of us so * queue up the item and schedule a task to process it */ SenderQueueItem qi = queueItem; if (qi == null) { qi = new SenderQueueItem(); qi.msg = msg; } TimerTask task = new TimerTask() { @Override public void run() { SenderQueueItem topItem = sendQueue.remove(); logger.debug("Checking credit"); if (enoughCredit(topItem.msg.requiredCredit(), topItem.msg.isFastSend())) { logger.debug("Sending item from queue. Message is " + topItem.msg.msgType + " => " + topItem.msg.rawMsg); if (topItem.msg.msgType == MaxCulMsgType.TIME_INFO) { ((TimeInfoMsg) topItem.msg).updateTime(); } transmitMessage(topItem.msg, topItem); } else { logger.error("Not enough credit after waiting. This is bad. Queued command is discarded"); } } }; timer = new Timer(); sendQueueTimers.put(qi, timer); /* * calculate when we want to TX this item in the queue, with a * margin of 2 credits. x1000 as we accumulate 1 x 10ms credit * every 1000ms */ int requiredCredit = msg.isFastSend() ? 0 : 100 + msg.requiredCredit() + 2; this.endOfQueueTransmit = new Date(this.endOfQueueTransmit.getTime() + (requiredCredit * 1000)); timer.schedule(task, this.endOfQueueTransmit); this.sendQueue.add(qi); logger.debug("Added message to queue to be TX'd at " + this.endOfQueueTransmit.toString()); } if (msg.isPartOfSequence()) { /* add to sequence register if part of a sequence */ logger.debug("Message " + msg.msgCount + " is part of sequence. Adding to register."); sequenceRegister.put(msg.msgCount, msg.getMessageSequencer()); } } else { logger.error("Tried to send a message that wasn't ready!"); } } /** * Associate binding processor with this message handler * * @param mcbmp * Binding processor to associate with this message handler */ public void registerMaxCulBindingMessageProcessor(MaxCulBindingMessageProcessor mcbmp) { if (this.mcbmp == null) { this.mcbmp = mcbmp; logger.debug("Associated MaxCulBindingMessageProcessor"); } else { logger.error("Tried to associate a second MaxCulBindingMessageProcessor!"); } } /** * Check the ACK queue for any pending acks that have expired */ public void checkPendingAcks() { Date now = new Date(); for (SenderQueueItem qi : pendingAckQueue.values()) { if (now.after(qi.expiry)) { logger.error("Packet " + qi.msg.msgCount + " (" + qi.msg.msgType + ") lost - timeout"); pendingAckQueue.remove(qi.msg.msgCount); // remove from ACK // queue if (sequenceRegister.containsKey(qi.msg.msgCount)) { /* message sequencer handles failed packet */ MessageSequencer msgSeq = sequenceRegister.get(qi.msg.msgCount); sequenceRegister.remove(qi.msg.msgCount); // remove from // register // first as // packetLost // could add it // again msgSeq.packetLost(qi.msg); } else if (qi.retryCount < 3) { /* retransmit */ qi.retryCount++; logger.debug("Retransmitting packet " + qi.msg.msgCount + " attempt " + qi.retryCount); sendMessage(qi.msg, qi); } else { logger.error("Transmission of packet {} failed 3 times, message was: {} to address {} => {}", qi.msg.msgCount, qi.msg.msgType, qi.msg.dstAddrStr, qi.msg.rawMsg); } } } } private void listenModeHandler(String data) { switch (BaseMsg.getMsgType(data)) { case WALL_THERMOSTAT_CONTROL: new WallThermostatControlMsg(data).printMessage(); break; case TIME_INFO: new TimeInfoMsg(data).printMessage(); break; case SET_TEMPERATURE: new SetTemperatureMsg(data).printMessage(); break; case ACK: new AckMsg(data).printMessage(); break; case PAIR_PING: new PairPingMsg(data).printMessage(); break; case PAIR_PONG: new PairPongMsg(data).printMessage(); break; case THERMOSTAT_STATE: new ThermostatStateMsg(data).printMessage(); break; case SET_GROUP_ID: new SetGroupIdMsg(data).printMessage(); break; case WAKEUP: new WakeupMsg(data).printMessage(); break; case WALL_THERMOSTAT_STATE: new WallThermostatStateMsg(data).printMessage(); break; case ADD_LINK_PARTNER: case CONFIG_TEMPERATURES: case CONFIG_VALVE: case CONFIG_WEEK_PROFILE: case PUSH_BUTTON_STATE: case REMOVE_GROUP_ID: case REMOVE_LINK_PARTNER: case RESET: case SET_COMFORT_TEMPERATURE: case SET_DISPLAY_ACTUAL_TEMP: case SET_ECO_TEMPERATURE: case SHUTTER_CONTACT_STATE: case UNKNOWN: default: BaseMsg baseMsg = new BaseMsg(data); baseMsg.printMessage(); break; } } @Override public void dataReceived(String data) { logger.debug("MaxCulSender Received " + data); if (data.startsWith("Z")) { if (listenMode) { listenModeHandler(data); return; } /* Check message is destined for us */ if (BaseMsg.isForUs(data, srcAddr)) { boolean passToBinding = true; /* Handle Internal Messages */ MaxCulMsgType msgType = BaseMsg.getMsgType(data); if (msgType == MaxCulMsgType.ACK) { passToBinding = false; AckMsg msg = new AckMsg(data); if (pendingAckQueue.containsKey(msg.msgCount) && msg.dstAddrStr.compareTo(srcAddr) == 0) { SenderQueueItem qi = pendingAckQueue.remove(msg.msgCount); /* verify ACK */ if ((qi.msg.dstAddrStr.equalsIgnoreCase(msg.srcAddrStr)) && (qi.msg.srcAddrStr.equalsIgnoreCase(msg.dstAddrStr))) { if (msg.getIsNack()) { /* NAK'd! */ // TODO resend? logger.error("Message was NAK'd, packet lost"); } else { logger.debug("Message " + msg.msgCount + " ACK'd ok!"); } } } else { logger.info("Got ACK for message " + msg.msgCount + " but it wasn't in the queue"); } } if (sequenceRegister.containsKey(new BaseMsg(data).msgCount)) { passToBinding = false; /* * message found in sequence register, so it will be handled * by the sequence */ BaseMsg bMsg = new BaseMsg(data); logger.debug("Message " + bMsg.msgCount + " is part of sequence. Running next step in sequence."); sequenceRegister.get(bMsg.msgCount).runSequencer(bMsg); sequenceRegister.remove(bMsg.msgCount); } if (passToBinding) { /* pass data to binding for processing */ this.mcbmp.maxCulMsgReceived(data, false); } } else if (BaseMsg.isForUs(data, "000000")) { switch (BaseMsg.getMsgType(data)) { case PAIR_PING: case WALL_THERMOSTAT_CONTROL: case THERMOSTAT_STATE: case WALL_THERMOSTAT_STATE: this.mcbmp.maxCulMsgReceived(data, true); break; default: /* TODO handle other broadcast */ logger.debug("Unhandled broadcast message of type " + BaseMsg.getMsgType(data).toString()); break; } } else { // Associated devices send messages that tell of their status to // the associated // device. We need to spy on devices we know about to extract // useful data boolean passToBinding = false; BaseMsg bMsg = new BaseMsg(data); for (MaxCulBindingProvider provider : providers) { // look up source device configs List<MaxCulBindingConfig> configs = provider.getConfigsForRadioAddr(bMsg.srcAddrStr); if (!configs.isEmpty()) { // get asssociated devices with source device String serialNum = configs.get(0).getSerialNumber(); HashSet<MaxCulBindingConfig> assocDevs = provider.getAssociations(serialNum); if (!assocDevs.isEmpty()) { // check for matches with associated devices and the // message dest for (MaxCulBindingConfig device : assocDevs) { if (device.getDevAddr().equalsIgnoreCase(bMsg.dstAddrStr)) { passToBinding = true; break; } } } } } if (passToBinding && (BaseMsg.getMsgType(data) != MaxCulMsgType.PAIR_PING && BaseMsg.getMsgType(data) != MaxCulMsgType.ACK)) { /* * pass data to binding for processing - pretend it is * broadcast so as not to ACK */ this.mcbmp.maxCulMsgReceived(data, true); } } } } @Override public void error(Exception e) { /* * Ignore errors for now - not sure what I would need to handle here at * the moment TODO lookup error cases */ logger.error("Received CUL Error", e); } /** * Send response to PairPing as part of a message sequence * * @param dstAddr * Address of device to respond to * @param msgSeq * Message sequence to associate */ public void sendPairPong(String dstAddr, MessageSequencer msgSeq) { PairPongMsg pp = new PairPongMsg(getMessageCount(), (byte) 0, (byte) 0, this.srcAddr, dstAddr); pp.setMessageSequencer(msgSeq); sendMessage(pp); } public void sendPairPong(String dstAddr) { sendPairPong(dstAddr, null); } /** * Send a wakeup message as part of a message sequence * * @param dstAddr * Address of device to respond to * @param msgSeq * Message sequence to associate */ public void sendWakeup(String devAddr, MessageSequencer msgSeq) { WakeupMsg msg = new WakeupMsg(getMessageCount(), (byte) 0x0, (byte) 0, this.srcAddr, devAddr); msg.setMessageSequencer(msgSeq); sendMessage(msg); } /** * Send time information to device that has requested it as part of a * message sequence * * @param dstAddr * Address of device to respond to * @param tzStr * Time Zone String * @param msgSeq * Message sequence to associate */ public void sendTimeInfo(String dstAddr, String tzStr, MessageSequencer msgSeq) { TimeInfoMsg msg = new TimeInfoMsg(getMessageCount(), (byte) 0x0, (byte) 0, this.srcAddr, dstAddr, tzStr); msg.setMessageSequencer(msgSeq); sendMessage(msg); } /** * Send time information to device in fast mode * * @param dstAddr * Address of device to respond to * @param tzStr * Time Zone String * @param msgSeq * Message sequence to associate */ public void sendTimeInfoFast(String dstAddr, String tzStr) { TimeInfoMsg msg = new TimeInfoMsg(getMessageCount(), (byte) 0x0, (byte) 0, this.srcAddr, dstAddr, tzStr); msg.setFastSend(true); sendMessage(msg); } /** * Send time information to device that has requested it * * @param dstAddr * Address of device to respond to * @param tzStr * Time Zone String */ public void sendTimeInfo(String dstAddr, String tzStr) { sendTimeInfo(dstAddr, tzStr, null); } /** * Set the group ID on a device * * @param devAddr * Address of device to set group ID on * @param group_id * Group id to set * @param msgSeq * Message sequence to associate */ public void sendSetGroupId(String devAddr, byte group_id, MessageSequencer msgSeq) { SetGroupIdMsg msg = new SetGroupIdMsg(getMessageCount(), (byte) 0x0, this.srcAddr, devAddr, group_id); msg.setMessageSequencer(msgSeq); sendMessage(msg); } /** * Send an ACK response to a message * * @param msg * Message we are acking */ public void sendAck(BaseMsg msg) { AckMsg ackMsg = new AckMsg(msg.msgCount, (byte) 0x0, msg.groupid, this.srcAddr, msg.srcAddrStr, false); ackMsg.setFastSend(true); // all ACKs are sent to waiting device. sendMessage(ackMsg); } /** * Send an NACK response to a message * * @param msg * Message we are nacking */ public void sendNack(BaseMsg msg) { AckMsg nackMsg = new AckMsg(msg.msgCount, (byte) 0x0, msg.groupid, this.srcAddr, msg.srcAddrStr, false); nackMsg.setFastSend(true); // all NACKs are sent to waiting device. sendMessage(nackMsg); } /** * Send a set temperature message * * @param devAddr * Radio addr of device * @param temp * Temperature value to send */ public void sendSetTemperature(String devAddr, double temp) { SetTemperatureMsg msg = new SetTemperatureMsg(getMessageCount(), (byte) 0x0, (byte) 0x0, this.srcAddr, devAddr, temp, ThermostatControlMode.MANUAL); sendMessage(msg); } /** * Send temperature configuration message * * @param devAddr * Radio addr of device * @param msgSeq * Message sequencer to associate with this message * @param comfortTemp * comfort temperature value * @param ecoTemp * Eco temperature value * @param maxTemp * Maximum Temperature value * @param minTemp * Minimum temperature value * @param offset * Offset Temperature value * @param windowOpenTemp * Window open temperature value * @param windowOpenTime * Window open time value */ public void sendConfigTemperatures(String devAddr, MessageSequencer msgSeq, double comfortTemp, double ecoTemp, double maxTemp, double minTemp, double offset, double windowOpenTemp, double windowOpenTime) { ConfigTemperaturesMsg cfgTempMsg = new ConfigTemperaturesMsg(getMessageCount(), (byte) 0, (byte) 0, this.srcAddr, devAddr, comfortTemp, ecoTemp, maxTemp, minTemp, offset, windowOpenTemp, windowOpenTime); cfgTempMsg.setMessageSequencer(msgSeq); sendMessage(cfgTempMsg); } /** * Link one device to another * * @param devAddr * Destination device address * @param msgSeq * Associated message sequencer * @param partnerAddr * Radio address of partner * @param devType * Type of device */ public void sendAddLinkPartner(String devAddr, MessageSequencer msgSeq, String partnerAddr, MaxCulDevice devType) { AddLinkPartnerMsg addLinkMsg = new AddLinkPartnerMsg(getMessageCount(), (byte) 0, (byte) 0, this.srcAddr, devAddr, partnerAddr, devType); addLinkMsg.setMessageSequencer(msgSeq); sendMessage(addLinkMsg); } /** * Send a reset message to device * * @param devAddr * Address of device to reset */ public void sendReset(String devAddr) { ResetMsg resetMsg = new ResetMsg(getMessageCount(), (byte) 0, (byte) 0, this.srcAddr, devAddr); sendMessage(resetMsg); } /** * Set listen mode status. Doing this will stop proper message processing * and will just turn this message handler into a snooper. * * @param listenModeOn * TRUE sets listen mode to ON */ public void setListenMode(boolean listenModeOn) { listenMode = listenModeOn; logger.debug("Listen Mode is " + (listenMode ? "ON" : "OFF")); } public void setLedMode(boolean ledModeOn) { String data = ""; if (ledModeOn) { data = "l02"; } else { data = "l00"; } try { cul.send(data); } catch (CULCommunicationException e) { logger.error("Unable to send CUL message " + data + " because: " + e.getMessage()); } } public void startSequence(MessageSequencer ps, BaseMsg msg) { logger.debug("Starting sequence"); ps.runSequencer(msg); } public int getCreditStatus() { return cul.getCredit10ms(); } public void sendSetDisplayActualTemp(String devAddr, boolean displayActualTemp) { SetDisplayActualTempMsg displaySettingMsg = new SetDisplayActualTempMsg(getMessageCount(), (byte) 0, (byte) 0, this.srcAddr, devAddr, displayActualTemp); sendMessage(displaySettingMsg); } }