/**
* 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.denon.internal;
import java.beans.Introspector;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Pattern;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.UnmarshalException;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.stream.util.StreamReaderDelegate;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.denon.internal.communication.entities.Deviceinfo;
import org.openhab.binding.denon.internal.communication.entities.Main;
import org.openhab.binding.denon.internal.communication.entities.ZoneStatus;
import org.openhab.binding.denon.internal.communication.entities.ZoneStatusLite;
import org.openhab.binding.denon.internal.communication.entities.commands.AppCommandRequest;
import org.openhab.binding.denon.internal.communication.entities.commands.AppCommandResponse;
import org.openhab.binding.denon.internal.communication.entities.commands.CommandRx;
import org.openhab.binding.denon.internal.communication.entities.commands.CommandTx;
import org.openhab.core.library.types.IncreaseDecreaseType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.ning.http.client.AsyncCompletionHandler;
import com.ning.http.client.AsyncHttpClient;
import com.ning.http.client.AsyncHttpClientConfig;
import com.ning.http.client.AsyncHttpClientConfig.Builder;
import com.ning.http.client.Response;
import com.ning.http.client.providers.netty.NettyAsyncHttpProvider;
/**
* This class makes the connection to the receiver and manages it.
* It is also responsible for sending commands to the receiver.
*
* @author Jeroen Idserda
* @since 1.7.0
*/
public class DenonConnector {
private static final Logger logger = LoggerFactory.getLogger(DenonConnector.class);
private static final int REQUEST_TIMEOUT_MS = 5000; // 5 seconds
// All regular commands. Example: PW, SICD, SITV, Z2MU
private static final Pattern COMMAND_PATTERN = Pattern.compile("^([A-Z0-9]{2})+(.+)$");
// Matches all secondary zone commands with a parameter. Example: Z2TUNER
private static final Pattern ZONE_SUBCOMMAND_PATTERN = Pattern
.compile("^(Z[0-9]{1}((?!ON|OFF|UP|DOWN)([A-Z]){2,}))$");
// Example: E2Counting Crows
private static final Pattern DISPLAY_PATTERN = Pattern.compile("^(E|A)([0-9]{1})(.+)$");
// Main URL for the receiver
private static final String URL_MAIN = "formMainZone_MainZoneXml.xml";
// Main Zone Status URL
private static final String URL_ZONE_MAIN = "formMainZone_MainZoneXmlStatus.xml";
// Secondary zone lite status URL (contains less info)
private static final String URL_ZONE_SECONDARY_LITE = "formZone%d_Zone%dXmlStatusLite.xml";
// Device info URL
private static final String URL_DEVICE_INFO = "Deviceinfo.xml";
// URL to send app commands to
private static final String URL_APP_COMMAND = "AppCommand.xml";
private static final BigDecimal NINETYNINE = new BigDecimal("99");
private static final BigDecimal POINTFIVE = new BigDecimal("0.5");
private static final String CONTENT_TYPE_XML = "application/xml";
private final DenonConnectionProperties connection;
private final String cmdUrl;
private final String statusUrl;
private final AsyncHttpClient client;
private final DenonPropertyUpdatedCallback callback;
private DenonListener listener;
private Map<String, State> stateCache = new HashMap<String, State>();
private ExecutorService executor = Executors.newFixedThreadPool(1);
private boolean displayNowplaying = false;
public DenonConnector(DenonConnectionProperties connection, DenonPropertyUpdatedCallback callback) {
this.connection = connection;
this.callback = callback;
this.client = new AsyncHttpClient(new NettyAsyncHttpProvider(createAsyncHttpClientConfig()));
this.cmdUrl = String.format("http://%s:%d/goform/formiPhoneAppDirect.xml?", connection.getHost(),
connection.getHttpPort());
this.statusUrl = String.format("http://%s:%d/goform/", connection.getHost(), connection.getHttpPort());
}
/**
* Set up a telnet connection to the receiver AND fetch initial state over HTTP.
*/
public void connect() {
if (connection.isTelnet()) {
listener = new DenonListener(connection, new DenonUpdateReceivedCallback() {
@Override
public void updateReceived(String command) {
processUpdate(command);
}
@Override
public void listenerConnected() {
getInitialState();
}
@Override
public void listenerDisconnected() {
sendUpdate(DenonProperty.POWER.getCode(), OnOffType.OFF);
}
});
listener.start();
}
getInitialState();
}
/**
* Close all connections
*/
public void disconnect() {
executor.shutdown();
if (listener != null) {
listener.shutdown();
}
}
/**
* Send a command for a certain property
*
* @param config The property
* @param command The command
*/
public void sendCommand(DenonBindingConfig config, Command command) {
String commandToSend = null;
Class<? extends Command> commandClass = command.getClass();
if (commandClass.equals(OnOffType.class)) {
commandToSend = getCommandFor(config, (OnOffType) command);
} else if (commandClass.equals(IncreaseDecreaseType.class)) {
commandToSend = getCommandFor(config, (IncreaseDecreaseType) command);
} else if (commandClass.equals(PercentType.class)) {
commandToSend = getCommandFor(config, (PercentType) command);
} else if (commandClass.equals(StringType.class)) {
commandToSend = getCommandFor(config, (StringType) command);
}
internalSendCommand(commandToSend);
}
/**
* Gets the current state of all properties from the receiver, including
* basic configuration info (like the number of zones)
*/
public void getInitialState() {
setConfigProperties();
updateState();
}
/**
* Update the value for all properties. Includes fetching it from the receiver.
*/
public void updateState() {
Date start = new Date();
logger.trace("Refresh Denon HTTP state");
refreshHttpProperties();
for (Entry<String, State> state : stateCache.entrySet()) {
callback.updated(connection.getInstance(), state.getKey(), state.getValue());
}
logger.trace("Refresh took {} ms", new Date().getTime() - start.getTime());
}
/**
* Update a single property from the state cache
*
* @param property The name of the property
*/
public void updateStateFromCache(String property) {
if (stateCache.containsKey(property)) {
callback.updated(connection.getInstance(), property, stateCache.get(property));
}
}
private String getCommandFor(DenonBindingConfig config, OnOffType onOff) {
String commandToSend = null;
String property = config.getActualProperty();
if (config.isOnOffProperty()) {
if (property.equals(DenonProperty.POWER.getCode())) {
if (OnOffType.ON.equals(onOff)) {
commandToSend = "PWON";
} else {
commandToSend = "PWSTANDBY";
}
} else {
commandToSend = property + onOff.name();
}
} else {
if (onOff.equals(OnOffType.ON)) {
commandToSend = property;
}
}
return commandToSend;
}
private String getCommandFor(DenonBindingConfig config, IncreaseDecreaseType increaseDecreaseType) {
String commandToSend = null;
String property = config.getActualProperty();
if (increaseDecreaseType.equals(IncreaseDecreaseType.INCREASE)) {
commandToSend = property + "UP";
} else {
commandToSend = property + "DOWN";
}
return commandToSend;
}
private String getCommandFor(DenonBindingConfig config, StringType stringType) {
String commandToSend = null;
String property = config.getActualProperty();
if (property.equals(DenonProperty.INPUT.getCode())) {
commandToSend = "SI" + stringType.toString();
} else if (property.equals(DenonProperty.COMMAND.getCode())) {
commandToSend = stringType.toString();
}
return commandToSend;
}
private String getCommandFor(DenonBindingConfig config, PercentType percentType) {
String property = config.getActualProperty();
String commandToSend = property + toDenonValue(percentType.toBigDecimal());
return commandToSend;
}
/**
* This method tries to parse information received over the telnet connection.
* It's quite unreliable. Some chars go missing or turn into other chars. That's
* why each command is validated using a regex.
*
* @param commandString The received command (one line)
*/
private void processUpdate(String commandString) {
if (COMMAND_PATTERN.matcher(commandString).matches()) {
/*
* This splits the commandString into the command and the parameter. SICD
* for example has SI as the command and CD as the parameter.
*/
String command = commandString.substring(0, 2);
String value = commandString.substring(2, commandString.length()).trim();
// Secondary zone commands with a parameter
if (ZONE_SUBCOMMAND_PATTERN.matcher(commandString).matches()) {
command = commandString.substring(0, 4);
value = commandString.substring(4, commandString.length()).trim();
}
logger.debug("Command: {}, value: {}", command, value);
if (value.equals("ON") || value.equals("OFF")) {
sendUpdate(command, OnOffType.valueOf(value));
} else if (value.equals("STANDBY")) {
sendUpdate(command, OnOffType.OFF);
} else if (StringUtils.isNumeric(value)) {
PercentType percent = new PercentType(fromDenonValue(value));
command = translateVolumeCommand(command);
sendUpdate(command, percent);
} else if (command.equals("SI")) {
sendUpdate(DenonProperty.INPUT.getCode(), new StringType(value));
sendUpdate(commandString, OnOffType.ON);
} else if (command.equals("MS")) {
sendUpdate(DenonProperty.SURROUND_MODE.getCode(), new StringType(value));
} else if (command.equals("NS")) {
processTitleCommand(command, value);
}
} else {
logger.debug("Invalid command: " + commandString);
}
}
private void processTitleCommand(String command, String value) {
if (DISPLAY_PATTERN.matcher(value).matches()) {
Integer commandNo = Integer.valueOf(value.substring(1, 2));
String titleValue = value.substring(2);
if (commandNo == 0) {
displayNowplaying = titleValue.contains("Now Playing");
}
State state = displayNowplaying ? new StringType(cleanupDisplayInfo(titleValue)) : UnDefType.UNDEF;
switch (commandNo) {
case 1:
sendUpdate(DenonProperty.TRACK.getCode(), state);
break;
case 2:
sendUpdate(DenonProperty.ARTIST.getCode(), state);
break;
case 4:
sendUpdate(DenonProperty.ALBUM.getCode(), state);
break;
}
}
}
private void sendUpdate(String property, State state) {
stateCache.put(property, state);
callback.updated(connection.getInstance(), property, state);
}
private String toDenonValue(BigDecimal percent) {
// Round to nearest number divisible by 0.5
percent = percent.divide(POINTFIVE).setScale(0, RoundingMode.UP).multiply(POINTFIVE)
.min(connection.getMainVolumeMax()).max(BigDecimal.ZERO);
String dbString = String.valueOf(percent.intValue());
if (percent.compareTo(BigDecimal.TEN) == -1) {
dbString = "0" + dbString;
}
if (percent.remainder(BigDecimal.ONE).equals(POINTFIVE)) {
dbString = dbString + "5";
}
return dbString;
}
private BigDecimal fromDenonValue(String string) {
/*
* 455 = 45,5
* 45 = 45
* 045 = 4,5
* 04 = 4
*/
BigDecimal value = new BigDecimal(string);
if (value.compareTo(NINETYNINE) == 1 || (string.startsWith("0") && string.length() > 2)) {
value = value.divide(BigDecimal.TEN);
}
return value;
}
private void internalSendCommand(String command) {
if (StringUtils.isBlank(command)) {
logger.warn("Trying to send empty command");
return;
}
try {
String url = cmdUrl + URLEncoder.encode(command, Charset.defaultCharset().displayName());
logger.trace("Calling url {}", url);
client.prepareGet(url).execute(new AsyncCompletionHandler<Response>() {
@Override
public Response onCompleted(Response response) throws Exception {
if (response.getStatusCode() != 200) {
logger.warn("Error {} while sending command", response.getStatusText());
}
return response;
}
@Override
public void onThrowable(Throwable t) {
logger.warn("Error sending command", t);
}
});
} catch (UnsupportedEncodingException e) {
logger.warn("Error preparing command", e);
} catch (IOException e) {
logger.warn("Error sending command", e);
}
}
private void updateMain() {
String url = statusUrl + URL_MAIN;
logger.trace("Refreshing URL: {}", url);
Main statusMain = getDocument(url, Main.class);
if (statusMain != null) {
stateCache.put(DenonProperty.POWER.getCode(),
statusMain.getPower().getValue() ? OnOffType.ON : OnOffType.OFF);
}
}
private void updateMainZone() {
String url = statusUrl + URL_ZONE_MAIN;
logger.trace("Refreshing URL: {}", url);
ZoneStatus mainZone = getDocument(url, ZoneStatus.class);
if (mainZone != null) {
stateCache.put(DenonProperty.INPUT.getCode(), new StringType(mainZone.getInputFuncSelect().getValue()));
stateCache.put("SI" + mainZone.getInputFuncSelect().getValue(), OnOffType.ON);
stateCache.put(DenonProperty.MASTER_VOLUME.getCode(),
new PercentType(mainZone.getMasterVolume().getValue()));
stateCache.put(DenonProperty.POWER_MAINZONE.getCode(),
mainZone.getPower().getValue() ? OnOffType.ON : OnOffType.OFF);
stateCache.put(DenonProperty.MUTE.getCode(), mainZone.getMute().getValue() ? OnOffType.ON : OnOffType.OFF);
if (mainZone.getSurrMode() == null) {
logger.debug("Unable to get the SURROUND_MODE. MainZone update may not be correct.");
} else {
stateCache.put(DenonProperty.SURROUND_MODE.getCode(),
new StringType(mainZone.getSurrMode().getValue()));
}
}
}
private void updateSecondaryZones() {
for (int i = 2; i <= connection.getZoneCount(); i++) {
String url = String.format("%s" + URL_ZONE_SECONDARY_LITE, statusUrl, i, i);
logger.trace("Refreshing URL: {}", url);
ZoneStatusLite zoneSecondary = getDocument(url, ZoneStatusLite.class);
if (zoneSecondary != null) {
stateCache.put("Z" + i, zoneSecondary.getPower().getValue() ? OnOffType.ON : OnOffType.OFF);
stateCache.put("Z" + i + DenonProperty.ZONE_VOLUME.getCode(),
new PercentType(zoneSecondary.getMasterVolume().getValue()));
stateCache.put("Z" + i + DenonProperty.MUTE.getCode(),
zoneSecondary.getMute().getValue() ? OnOffType.ON : OnOffType.OFF);
}
}
}
private void updateDisplayInfo() {
String url = statusUrl + URL_APP_COMMAND;
logger.trace("Refreshing URL: {}", url);
AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_NET_STATUS);
AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
if (response != null) {
CommandRx titleInfo = response.getCommands().get(0);
stateCache.put(DenonProperty.TRACK.getCode(), getStateForValue(titleInfo.getText("track")));
stateCache.put(DenonProperty.ARTIST.getCode(), getStateForValue(titleInfo.getText("artist")));
stateCache.put(DenonProperty.ALBUM.getCode(), getStateForValue(titleInfo.getText("album")));
}
}
private void setConfigProperties() {
String url = statusUrl + URL_DEVICE_INFO;
logger.debug("Refreshing URL: {}", url);
Deviceinfo deviceinfo = getDocument(url, Deviceinfo.class);
if (deviceinfo != null) {
connection.setZoneCount(deviceinfo.getDeviceZones());
}
/**
* The maximum volume is received from the telnet connection in the
* form of the MVMAX property. It is not always received reliable however,
* so we're using a default for now.
*/
connection.setMainVolumeMax(DenonConnectionProperties.MAX_VOLUME);
logger.debug("Denon {} zones: {}", connection.getInstance(), connection.getZoneCount());
}
private void refreshHttpProperties() {
logger.trace("Refreshing Denon status");
stateCache.clear();
updateMain();
updateMainZone();
updateSecondaryZones();
updateDisplayInfo();
}
/**
* Translate the volume command from the receiver to the openHAB property.
*
* Z2 -> Z2ZV
* Z3 -> Z3ZV, etc
*
* @param command The command from the receiver
* @return The property name in openHAB
*/
private String translateVolumeCommand(String command) {
if (command.matches("Z[0-9]")) {
command = command + DenonProperty.ZONE_VOLUME.getCode();
}
return command;
}
private State getStateForValue(String value) {
if (StringUtils.isBlank(value)) {
return UnDefType.UNDEF;
}
return new StringType(value);
}
/**
* Display info could contain some garbled text, attempt to clean it up.
*/
private String cleanupDisplayInfo(String titleValue) {
byte firstByteRemoved[] = Arrays.copyOfRange(titleValue.getBytes(), 1, titleValue.getBytes().length);
titleValue = new String(firstByteRemoved).replaceAll("[\u0000-\u001f]", "");
return titleValue;
}
private AsyncHttpClientConfig createAsyncHttpClientConfig() {
Builder builder = new AsyncHttpClientConfig.Builder();
builder.setRequestTimeoutInMs(REQUEST_TIMEOUT_MS);
builder.setUseRawUrl(true);
return builder.build();
}
private <T> T getDocument(String uri, Class<T> response) {
try {
String result = doHttpRequest("GET", uri, null);
logger.trace("result of getDocument for uri '{}':\r\n{}", uri, result);
if (StringUtils.isNotBlank(result)) {
JAXBContext jc = JAXBContext.newInstance(response);
XMLInputFactory xif = XMLInputFactory.newInstance();
XMLStreamReader xsr = xif.createXMLStreamReader(IOUtils.toInputStream(result));
xsr = new PropertyRenamerDelegate(xsr);
@SuppressWarnings("unchecked")
T obj = (T) jc.createUnmarshaller().unmarshal(xsr);
return obj;
}
} catch (UnmarshalException e) {
logger.debug("Failed to unmarshal xml document: {}", e.getMessage());
} catch (JAXBException e) {
logger.debug("Unexpected error occurred during unmarshalling of document: {}", e.getMessage());
} catch (XMLStreamException e) {
logger.debug("Communication error: {}", e.getMessage());
}
return null;
}
private <T, S> T postDocument(String uri, Class<T> response, S request) {
try {
JAXBContext jaxbContext = JAXBContext.newInstance(request.getClass());
Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
StringWriter sw = new StringWriter();
jaxbMarshaller.marshal(request, sw);
String result = doHttpRequest("POST", uri, sw.toString());
if (StringUtils.isNotBlank(result)) {
JAXBContext jcResponse = JAXBContext.newInstance(response);
@SuppressWarnings("unchecked")
T obj = (T) jcResponse.createUnmarshaller().unmarshal(IOUtils.toInputStream(result));
return obj;
}
} catch (JAXBException e) {
logger.debug("Encoding error in post", e);
}
return null;
}
private String doHttpRequest(String method, String uri, String request) {
try {
HttpURLConnection connection = (HttpURLConnection) new URL(uri).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(REQUEST_TIMEOUT_MS);
connection.setReadTimeout(REQUEST_TIMEOUT_MS);
connection.addRequestProperty("Content-Type", CONTENT_TYPE_XML);
if (request != null) {
connection.setDoOutput(true);
connection.getOutputStream().write(request.getBytes());
}
InputStream is = connection.getInputStream();
String ret = IOUtils.toString(is);
connection.disconnect();
return ret;
} catch (IOException e) {
logger.debug("HTTP communication error", e);
}
return null;
}
private static class PropertyRenamerDelegate extends StreamReaderDelegate {
public PropertyRenamerDelegate(XMLStreamReader xsr) {
super(xsr);
}
@Override
public String getAttributeLocalName(int index) {
return Introspector.decapitalize(super.getAttributeLocalName(index));
}
@Override
public String getLocalName() {
return Introspector.decapitalize(super.getLocalName());
}
}
}