added migrated 2.x add-ons

Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Kai Kreuzer
2020-09-21 01:58:32 +02:00
parent bbf1a7fd29
commit 6df6783b60
11662 changed files with 1302875 additions and 11 deletions

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.russound-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-russound" description="Russound Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.russound/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link RussoundBinding} class defines common constants, which are
* used across the whole binding.
*
* @author Tim Roberts - Initial contribution
*/
@NonNullByDefault
public class RussoundBindingConstants {
public static final String BINDING_ID = "russound";
}

View File

@@ -0,0 +1,90 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal;
import static org.openhab.binding.russound.internal.rio.RioConstants.*;
import java.util.Collections;
import java.util.Hashtable;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.openhab.binding.russound.internal.discovery.RioSystemDeviceDiscoveryService;
import org.openhab.binding.russound.internal.rio.controller.RioControllerHandler;
import org.openhab.binding.russound.internal.rio.source.RioSourceHandler;
import org.openhab.binding.russound.internal.rio.system.RioSystemHandler;
import org.openhab.binding.russound.internal.rio.zone.RioZoneHandler;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link RussoundHandlerFactory} is responsible for creating bridge and thing
* handlers.
*
* @author Tim Roberts - Initial contribution
*/
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.russound")
public class RussoundHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(RussoundHandlerFactory.class);
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
.unmodifiableSet(Stream.of(BRIDGE_TYPE_RIO, BRIDGE_TYPE_CONTROLLER, THING_TYPE_SOURCE, THING_TYPE_ZONE)
.collect(Collectors.toSet()));
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(BRIDGE_TYPE_RIO)) {
final RioSystemHandler sysHandler = new RioSystemHandler((Bridge) thing);
registerThingDiscovery(sysHandler);
return sysHandler;
} else if (thingTypeUID.equals(BRIDGE_TYPE_CONTROLLER)) {
return new RioControllerHandler((Bridge) thing);
} else if (thingTypeUID.equals(THING_TYPE_SOURCE)) {
return new RioSourceHandler(thing);
} else if (thingTypeUID.equals(THING_TYPE_ZONE)) {
return new RioZoneHandler(thing);
}
return null;
}
/**
* Registers a {@link RioSystemDeviceDiscoveryService} from the passed {@link RioSystemHandler} and activates it.
*
* @param bridgeHandler the {@link RioSystemHandler} for discovery services
*/
private synchronized void registerThingDiscovery(RioSystemHandler bridgeHandler) {
RioSystemDeviceDiscoveryService discoveryService = new RioSystemDeviceDiscoveryService(bridgeHandler);
logger.trace("Try to register Discovery service on BundleID: {} Service: {}",
bundleContext.getBundle().getBundleId(), DiscoveryService.class.getName());
bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>());
discoveryService.activate();
}
}

View File

@@ -0,0 +1,264 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.discovery;
import java.io.IOException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.RussoundHandlerFactory;
import org.openhab.binding.russound.internal.net.SocketChannelSession;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.WaitingSessionListener;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.controller.RioControllerConfig;
import org.openhab.binding.russound.internal.rio.source.RioSourceConfig;
import org.openhab.binding.russound.internal.rio.system.RioSystemHandler;
import org.openhab.binding.russound.internal.rio.zone.RioZoneConfig;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.ThingUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This implementation of {@link DiscoveryService} will scan a RIO device for all controllers, source and zones attached
* to it.
*
* @author Tim Roberts - Initial contribution
*/
public class RioSystemDeviceDiscoveryService extends AbstractDiscoveryService {
/** The logger */
private final Logger logger = LoggerFactory.getLogger(RioSystemDeviceDiscoveryService.class);
/** The system handler to scan */
private final RioSystemHandler sysHandler;
/** Pattern to identify controller notifications */
private static final Pattern RSP_CONTROLLERNOTIFICATION = Pattern
.compile("(?i)^[SN] C\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
/** Pattern to identify source notifications */
private static final Pattern RSP_SRCNOTIFICATION = Pattern.compile("(?i)^[SN] S\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
/** Pattern to identify zone notifications */
private static final Pattern RSP_ZONENOTIFICATION = Pattern
.compile("(?i)^[SN] C\\[(\\d+)\\]\\.Z\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
/**
* The {@link SocketSession} that will be used to scan the device
*/
private SocketSession session;
/**
* The {@link WaitingSessionListener} to the {@link #session} to receive/process responses
*/
private WaitingSessionListener listener;
/**
* Create the discovery service from the {@link RioSystemHandler}
*
* @param sysHandler a non-null {@link RioSystemHandler}
* @throws IllegalArgumentException if sysHandler is null
*/
public RioSystemDeviceDiscoveryService(RioSystemHandler sysHandler) {
super(RussoundHandlerFactory.SUPPORTED_THING_TYPES_UIDS, 30, false);
if (sysHandler == null) {
throw new IllegalArgumentException("sysHandler can't be null");
}
this.sysHandler = sysHandler;
}
/**
* Activates this discovery service. Simply registers this with
* {@link RioSystemHandler#registerDiscoveryService(RioSystemDeviceDiscoveryService)}
*/
public void activate() {
sysHandler.registerDiscoveryService(this);
}
/**
* Deactivates the scan - will disconnect the session and remove the {@link #listener}
*/
@Override
public void deactivate() {
if (session != null) {
try {
session.disconnect();
} catch (IOException e) {
// ignore
}
session.removeListener(listener);
session = null;
listener = null;
}
}
/**
* Overridden to do nothing - {@link #scanDevice()} is called by {@link RioSystemHandler} instead
*/
@Override
protected void startScan() {
// do nothing - started by RioSystemHandler
}
/**
* Starts a device scan. This will connect to the device and discover the controllers/sources/zones attached to the
* device and then disconnect via {@link #deactivate()}
*/
public void scanDevice() {
try {
final String ipAddress = sysHandler.getRioConfig().getIpAddress();
session = new SocketChannelSession(ipAddress, RioConstants.RIO_PORT);
listener = new WaitingSessionListener();
session.addListener(listener);
try {
logger.debug("Starting scan of RIO device at {}", ipAddress);
session.connect();
discoverControllers();
discoverSources();
} catch (IOException e) {
logger.debug("Trying to scan device but couldn't connect: {}", e.getMessage(), e);
}
} finally {
deactivate();
}
}
/**
* Helper method to discover controllers - this will iterate through all possible controllers (6 of them )and see if
* any respond to the "type" command. If they do, we initiate a {@link #thingDiscovered(DiscoveryResult)} for the
* controller and then scan the controller for zones via {@link #discoverZones(ThingUID, int)}
*/
private void discoverControllers() {
for (int c = 1; c < 7; c++) {
final String type = sendAndGet("GET C[" + c + "].type", RSP_CONTROLLERNOTIFICATION, 3);
if (StringUtils.isNotEmpty(type)) {
logger.debug("Controller #{} found - {}", c, type);
final ThingUID thingUID = new ThingUID(RioConstants.BRIDGE_TYPE_CONTROLLER,
sysHandler.getThing().getUID(), String.valueOf(c));
final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID)
.withProperty(RioControllerConfig.CONTROLLER, c).withBridge(sysHandler.getThing().getUID())
.withLabel("Controller #" + c).build();
thingDiscovered(discoveryResult);
discoverZones(thingUID, c);
}
}
}
/**
* Helper method to discover sources. This will iterate through all possible sources (8 of them) and see if they
* respond to the "type" command. If they do, we retrieve the source "name" and initial a
* {@link #thingDiscovered(DiscoveryResult)} for the source.
*/
private void discoverSources() {
for (int s = 1; s < 9; s++) {
final String type = sendAndGet("GET S[" + s + "].type", RSP_SRCNOTIFICATION, 3);
if (StringUtils.isNotEmpty(type)) {
final String name = sendAndGet("GET S[" + s + "].name", RSP_SRCNOTIFICATION, 3);
logger.debug("Source #{} - {}/{}", s, type, name);
final ThingUID thingUID = new ThingUID(RioConstants.THING_TYPE_SOURCE, sysHandler.getThing().getUID(),
String.valueOf(s));
final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID)
.withProperty(RioSourceConfig.SOURCE, s).withBridge(sysHandler.getThing().getUID())
.withLabel((StringUtils.isEmpty(name) || name.equalsIgnoreCase("null") ? "Source" : name) + " ("
+ s + ")")
.build();
thingDiscovered(discoveryResult);
}
}
}
/**
* Helper method to discover zones. This will iterate through all possible zones (8 of them) and see if they
* respond to the "name" command. If they do, initial a {@link #thingDiscovered(DiscoveryResult)} for the zone.
*
* @param controllerUID the {@link ThingUID} of the parent controller
* @param c the controller identifier
* @throws IllegalArgumentException if controllerUID is null
* @throws IllegalArgumentException if c is < 1 or > 8
*/
private void discoverZones(ThingUID controllerUID, int c) {
if (controllerUID == null) {
throw new IllegalArgumentException("controllerUID cannot be null");
}
if (c < 1 || c > 8) {
throw new IllegalArgumentException("c must be between 1 and 8");
}
for (int z = 1; z < 9; z++) {
final String name = sendAndGet("GET C[" + c + "].Z[" + z + "].name", RSP_ZONENOTIFICATION, 4);
if (StringUtils.isNotEmpty(name)) {
logger.debug("Controller #{}, Zone #{} found - {}", c, z, name);
final ThingUID thingUID = new ThingUID(RioConstants.THING_TYPE_ZONE, controllerUID, String.valueOf(z));
final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID)
.withProperty(RioZoneConfig.ZONE, z).withBridge(controllerUID)
.withLabel((name.equalsIgnoreCase("null") ? "Zone" : name) + " (" + z + ")").build();
thingDiscovered(discoveryResult);
}
}
}
/**
* Helper method to send a message, parse the result with the given {@link Pattern} and extract the data in the
* specified group number.
*
* @param message the message to send
* @param respPattern the response pattern to apply
* @param groupNum the group # to return
* @return a possibly null response (null if an exception occurs or the response isn't a match or the response
* doesn't have the right amount of groups)
* @throws IllegalArgumentException if message is null or empty, if the pattern is null
* @throws IllegalArgumentException if groupNum is less than 0
*/
private String sendAndGet(String message, Pattern respPattern, int groupNum) {
if (StringUtils.isEmpty(message)) {
throw new IllegalArgumentException("message cannot be a null or empty string");
}
if (respPattern == null) {
throw new IllegalArgumentException("respPattern cannot be null");
}
if (groupNum < 0) {
throw new IllegalArgumentException("groupNum must be >= 0");
}
try {
session.sendCommand(message);
final String r = listener.getResponse();
final Matcher m = respPattern.matcher(r);
if (m.matches() && m.groupCount() >= groupNum) {
logger.debug("Message '{}' returned an valid response: {}", message, r);
return m.group(groupNum);
}
logger.debug("Message '{}' returned an invalid response: {}", message, r);
return null;
} catch (InterruptedException e) {
logger.debug("Sending message '{}' was interrupted and could not be completed", message);
return null;
} catch (IOException e) {
logger.debug("Sending message '{}' resulted in an IOException and could not be completed: {}", message,
e.getMessage(), e);
return null;
}
}
}

View File

@@ -0,0 +1,226 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.discovery;
import java.io.IOException;
import java.net.Inet6Address;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.net.util.SubnetUtils;
import org.openhab.binding.russound.internal.net.SocketChannelSession;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.WaitingSessionListener;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.system.RioSystemConfig;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This implementation of {@link DiscoveryService} will scan the network for any Russound RIO system devices. The scan
* will occur against all network interfaces.
*
* @author Tim Roberts - Initial contribution
*/
@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.russound")
public class RioSystemDiscovery extends AbstractDiscoveryService {
/** The logger */
private final Logger logger = LoggerFactory.getLogger(RioSystemDiscovery.class);
/** The timeout to connect (in milliseconds) */
private static final int CONN_TIMEOUT_IN_MS = 100;
/** The {@link ExecutorService} to use for scanning - will be null if not scanning */
private ExecutorService executorService = null;
/** The number of network interfaces being scanned */
private int nbrNetworkInterfacesScanning = 0;
/**
* Creates the system discovery service looking for {@link RioConstants#BRIDGE_TYPE_RIO}. The scan will take at most
* 120 seconds (depending on how many network interfaces there are)
*/
public RioSystemDiscovery() {
super(Collections.singleton(RioConstants.BRIDGE_TYPE_RIO), 120);
}
/**
* Starts the scan. For each network interface (that is up and not a loopback), all addresses will be iterated
* and checked for something open on port 9621. If that port is open, a russound controller "type" command will be
* issued. If the response is a correct pattern, we assume it's a rio system device and will emit a
* {{@link #thingDiscovered(DiscoveryResult)}
*/
@Override
protected void startScan() {
final List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (SocketException e1) {
logger.debug("Exception getting network interfaces: {}", e1.getMessage(), e1);
return;
}
nbrNetworkInterfacesScanning = interfaces.size();
executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 10);
for (final NetworkInterface networkInterface : interfaces) {
try {
if (networkInterface.isLoopback() || !networkInterface.isUp()) {
continue;
}
} catch (SocketException e) {
continue;
}
for (Iterator<InterfaceAddress> it = networkInterface.getInterfaceAddresses().iterator(); it.hasNext();) {
final InterfaceAddress interfaceAddress = it.next();
// don't bother with ipv6 addresses (russound doesn't support)
if (interfaceAddress.getAddress() instanceof Inet6Address) {
continue;
}
final String subnetRange = interfaceAddress.getAddress().getHostAddress() + "/"
+ interfaceAddress.getNetworkPrefixLength();
logger.debug("Scanning subnet: {}", subnetRange);
final SubnetUtils utils = new SubnetUtils(subnetRange);
final String[] addresses = utils.getInfo().getAllAddresses();
for (final String address : addresses) {
executorService.execute(() -> {
scanAddress(address);
});
}
}
}
// Finishes the scan and cleans up
stopScan();
}
/**
* Stops the scan by terminating the {@link #executorService} and shutting it down
*/
@Override
protected synchronized void stopScan() {
super.stopScan();
if (executorService == null) {
return;
}
try {
executorService.awaitTermination(CONN_TIMEOUT_IN_MS * nbrNetworkInterfacesScanning, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
// shutting down - doesn't matter
}
executorService.shutdown();
executorService = null;
}
/**
* Helper method to scan a specific address. Will open up port 9621 on the address and if opened, query for any
* controller type (all 6 controllers are tested). If a valid type is found, a discovery result will be created.
*
* @param ipAddress a possibly null, possibly empty ip address (null/empty addresses will be ignored)
*/
private void scanAddress(String ipAddress) {
if (StringUtils.isEmpty(ipAddress)) {
return;
}
final SocketSession session = new SocketChannelSession(ipAddress, RioConstants.RIO_PORT);
try {
final WaitingSessionListener listener = new WaitingSessionListener();
session.addListener(listener);
session.connect(CONN_TIMEOUT_IN_MS);
logger.debug("Connected to port {}:{} - testing to see if RIO", ipAddress, RioConstants.RIO_PORT);
// can't check for system properties because DMS responds to those -
// need to check if any controllers are defined
for (int c = 1; c < 7; c++) {
session.sendCommand("GET C[" + c + "].type");
final String resp = listener.getResponse();
if (resp == null) {
continue;
}
if (!resp.startsWith("S C[" + c + "].type=\"")) {
continue;
}
final String type = resp.substring(13, resp.length() - 1);
if (!StringUtils.isBlank(type)) {
logger.debug("Found a RIO type #{}", type);
addResult(ipAddress, type);
break;
}
}
} catch (InterruptedException e) {
logger.debug("Connection was interrupted to port {}:{}", ipAddress, RioConstants.RIO_PORT);
} catch (IOException e) {
logger.trace("Connection couldn't be established to port {}:{}", ipAddress, RioConstants.RIO_PORT);
} finally {
try {
session.disconnect();
} catch (IOException e) {
// do nothing
}
}
}
/**
* Helper method to add our ip address and system type as a discovery result.
*
* @param ipAddress a non-null, non-empty ip address
* @param type a non-null, non-empty model type
* @throws IllegalArgumentException if ipaddress or type is null or empty
*/
private void addResult(String ipAddress, String type) {
if (StringUtils.isEmpty(ipAddress)) {
throw new IllegalArgumentException("ipAddress cannot be null or empty");
}
if (StringUtils.isEmpty(type)) {
throw new IllegalArgumentException("type cannot be null or empty");
}
final Map<String, Object> properties = new HashMap<>(3);
properties.put(RioSystemConfig.IP_ADDRESS, ipAddress);
properties.put(RioSystemConfig.PING, 30);
properties.put(RioSystemConfig.RETRY_POLLING, 10);
properties.put(RioSystemConfig.SCAN_DEVICE, true);
final String id = ipAddress.replace(".", "");
final ThingUID uid = new ThingUID(RioConstants.BRIDGE_TYPE_RIO, id);
if (uid != null) {
final DiscoveryResult result = DiscoveryResultBuilder.create(uid).withProperties(properties)
.withLabel("Russound " + type).build();
thingDiscovered(result);
}
}
}

View File

@@ -0,0 +1,319 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.net;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousCloseException;
import java.nio.channels.SocketChannel;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Represents a restartable socket connection to the underlying telnet session. Commands can be sent via
* {@link #sendCommand(String)} and responses will be received on any {@link SocketSessionListener}. This implementation
* of {@link SocketSession} communicates using a {@link SocketChannel} connection.
*
* @author Tim Roberts - Initial contribution
*/
public class SocketChannelSession implements SocketSession {
private final Logger logger = LoggerFactory.getLogger(SocketChannelSession.class);
/**
* The host/ip address to connect to
*/
private final String host;
/**
* The port to connect to
*/
private final int port;
/**
* The actual socket being used. Will be null if not connected
*/
private final AtomicReference<SocketChannel> socketChannel = new AtomicReference<>();
/**
* The responses read from the {@link #responseReader}
*/
private final BlockingQueue<Object> responses = new ArrayBlockingQueue<>(50);
/**
* The {@link SocketSessionListener} that the {@link #dispatcher} will call
*/
private List<SocketSessionListener> sessionListeners = new CopyOnWriteArrayList<>();
/**
* The thread dispatching responses - will be null if not connected
*/
private Thread dispatchingThread = null;
/**
* The thread processing responses - will be null if not connected
*/
private Thread responseThread = null;
/**
* Creates the socket session from the given host and port
*
* @param host a non-null, non-empty host/ip address
* @param port the port number between 1 and 65535
*/
public SocketChannelSession(String host, int port) {
if (host == null || host.trim().length() == 0) {
throw new IllegalArgumentException("Host cannot be null or empty");
}
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("Port must be between 1 and 65535");
}
this.host = host;
this.port = port;
}
@Override
public void addListener(SocketSessionListener listener) {
if (listener == null) {
throw new IllegalArgumentException("listener cannot be null");
}
sessionListeners.add(listener);
}
@Override
public void clearListeners() {
sessionListeners.clear();
}
@Override
public boolean removeListener(SocketSessionListener listener) {
return sessionListeners.remove(listener);
}
@Override
public void connect() throws IOException {
connect(2000);
}
@Override
public void connect(int timeout) throws IOException {
disconnect();
final SocketChannel channel = SocketChannel.open();
channel.configureBlocking(true);
logger.debug("Connecting to {}:{}", host, port);
channel.socket().connect(new InetSocketAddress(host, port), timeout);
socketChannel.set(channel);
responses.clear();
dispatchingThread = new Thread(new Dispatcher());
responseThread = new Thread(new ResponseReader());
dispatchingThread.start();
responseThread.start();
}
@Override
public void disconnect() throws IOException {
if (isConnected()) {
logger.debug("Disconnecting from {}:{}", host, port);
final SocketChannel channel = socketChannel.getAndSet(null);
channel.close();
dispatchingThread.interrupt();
dispatchingThread = null;
responseThread.interrupt();
responseThread = null;
responses.clear();
}
}
@Override
public boolean isConnected() {
final SocketChannel channel = socketChannel.get();
return channel != null && channel.isConnected();
}
@Override
public synchronized void sendCommand(String command) throws IOException {
if (command == null) {
throw new IllegalArgumentException("command cannot be null");
}
// if (command.trim().length() == 0) {
// throw new IllegalArgumentException("Command cannot be empty");
// }
if (!isConnected()) {
throw new IOException("Cannot send message - disconnected");
}
ByteBuffer toSend = ByteBuffer.wrap((command + "\r\n").getBytes());
final SocketChannel channel = socketChannel.get();
if (channel == null) {
logger.debug("Cannot send command '{}' - socket channel was closed", command);
} else {
logger.debug("Sending Command: '{}'", command);
channel.write(toSend);
}
}
/**
* This is the runnable that will read from the socket and add messages to the responses queue (to be processed by
* the dispatcher)
*
* @author Tim Roberts
*
*/
private class ResponseReader implements Runnable {
/**
* Runs the logic to read from the socket until {@link #isRunning} is false. A 'response' is anything that ends
* with a carriage-return/newline combo. Additionally, the special "Login: " and "Password: " prompts are
* treated as responses for purposes of logging in.
*/
@Override
public void run() {
final StringBuilder sb = new StringBuilder(100);
final ByteBuffer readBuffer = ByteBuffer.allocate(1024);
responses.clear();
while (!Thread.currentThread().isInterrupted()) {
try {
// if reader is null, sleep and try again
if (readBuffer == null) {
Thread.sleep(250);
continue;
}
final SocketChannel channel = socketChannel.get();
if (channel == null) {
// socket was closed
Thread.currentThread().interrupt();
break;
}
int bytesRead = channel.read(readBuffer);
if (bytesRead == -1) {
responses.put(new IOException("server closed connection"));
break;
} else if (bytesRead == 0) {
readBuffer.clear();
continue;
}
readBuffer.flip();
while (readBuffer.hasRemaining()) {
final char ch = (char) readBuffer.get();
sb.append(ch);
if (ch == '\n' || ch == ' ') {
final String str = sb.toString();
if (str.endsWith("\r\n") || str.endsWith("Login: ") || str.endsWith("Password: ")) {
sb.setLength(0);
final String response = str.substring(0, str.length() - 2);
responses.put(response);
}
}
}
readBuffer.flip();
} catch (InterruptedException e) {
// Ending thread execution
Thread.currentThread().interrupt();
} catch (AsynchronousCloseException e) {
// socket was closed by another thread but interrupt our loop anyway
Thread.currentThread().interrupt();
} catch (IOException e) {
// set before pushing the response since we'll likely call back our stop
Thread.currentThread().interrupt();
try {
responses.put(e);
break;
} catch (InterruptedException e1) {
// Do nothing - probably shutting down
// Since we set isRunning to false, will drop out of loop and end the thread
}
}
}
}
}
/**
* The dispatcher runnable is responsible for reading the response queue and dispatching it to the current callable.
* Since the dispatcher is ONLY started when a callable is set, responses may pile up in the queue and be dispatched
* when a callable is set. Unlike the socket reader, this can be assigned to another thread (no state outside of the
* class).
*
* @author Tim Roberts
*/
private class Dispatcher implements Runnable {
/**
* Runs the logic to dispatch any responses to the current listeners until {@link #isRunning} is false.
*/
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
final SocketSessionListener[] listeners = sessionListeners.toArray(new SocketSessionListener[0]);
// if no listeners, we don't want to start dispatching yet.
if (listeners.length == 0) {
Thread.sleep(250);
continue;
}
final Object response = responses.poll(1, TimeUnit.SECONDS);
if (response != null) {
if (response instanceof String) {
logger.debug("Dispatching response: {}", response);
for (SocketSessionListener listener : listeners) {
listener.responseReceived((String) response);
}
} else if (response instanceof IOException) {
logger.debug("Dispatching exception: {}", response);
for (SocketSessionListener listener : listeners) {
listener.responseException((IOException) response);
}
} else {
logger.warn("Unknown response class: {}", response);
}
}
} catch (InterruptedException e) {
// Ending thread execution
Thread.currentThread().interrupt();
} catch (Exception e) {
logger.debug("Uncaught exception {}: ", e.getMessage(), e);
Thread.currentThread().interrupt();
}
}
}
}
}

View File

@@ -0,0 +1,87 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.net;
import java.io.IOException;
/**
* This is a socket session interface that defines the contract for a socket session. A socket session will initiate
* communications with the underlying device and provide message back via the {@link SocketSessionListener}
*
* @author Tim Roberts - Initial contribution
*/
public interface SocketSession {
/**
* Adds a {@link SocketSessionListener} to call when responses/exceptions have been received
*
* @param listener a non-null {@link SocketSessionListener} to use
*/
void addListener(SocketSessionListener listener);
/**
* Clears all listeners
*/
void clearListeners();
/**
* Removes a {@link SocketSessionListener} from this session
*
* @param listener a non-null {@link SocketSessionListener} to remove
* @return true if removed, false otherwise
*/
boolean removeListener(SocketSessionListener listener);
/**
* Will attempt to connect to the {@link #_host} on port {@link #_port}. Simply calls {@link #connect(int)} with
* a 2 second timeout
*
* @throws java.io.IOException if an exception occurs during the connection attempt
*/
void connect() throws IOException;
/**
* Will attempt to connect to the {@link #_host} on port {@link #_port}. If we are current connected, will
* {@link #disconnect()} first. Once connected, the {@link #_writer} and {@link #_reader} will be created, the
* {@link #_dispatcher} and {@link #_responseReader} will be started.
*
* @param timeout a connection timeout (in milliseconds)
* @throws java.io.IOException if an exception occurs during the connection attempt
*/
void connect(int timeout) throws IOException;
/**
* Disconnects from the {@link #_host} if we are {@link #isConnected()}. The {@link #_writer}, {@link #_reader} and
* {@link #_client}
* will be closed and set to null. The {@link #_dispatcher} and {@link #_responseReader} will be stopped, the
* {@link #_listeners} will be nulled and the {@link #_responses} will be cleared.
*
* @throws java.io.IOException if an exception occurs during the disconnect attempt
*/
void disconnect() throws IOException;
/**
* Returns true if we are connected ({@link #_client} is not null and is connected)
*
* @return true if connected, false otherwise
*/
boolean isConnected();
/**
* Sends the specified command to the underlying socket
*
* @param command a non-null, non-empty command
* @throws java.io.IOException an exception that occurred while sending
*/
void sendCommand(String command) throws IOException;
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.net;
import java.io.IOException;
/**
* Interface defining a listener to a {@link SocketSession} that will receive responses and/or exceptions from the
* socket
*
* @author Tim Roberts - Initial contribution
*/
public interface SocketSessionListener {
/**
* Called when a command has completed with the response for the command
*
* @param response a non-null, possibly empty response
* @throws InterruptedException if the response processing was interrupted
*/
public void responseReceived(String response) throws InterruptedException;
/**
* Called when a command finished with an exception or a general exception occurred while reading
*
* @param e a non-null io exception
* @throws InterruptedException if the exception processing was interrupted
*/
public void responseException(IOException e) throws InterruptedException;
}

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.net;
import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
/**
* Implementation of {@link SocketSessionListener} that allows a caller to wait for a response via
* {@link #getResponse()}
*
* @author Tim Roberts - Initial contribution
*/
public class WaitingSessionListener implements SocketSessionListener {
/**
* Cache of responses that have occurred
*/
private BlockingQueue<Object> responses = new ArrayBlockingQueue<>(5);
/**
* Will return the next response from {@link #responses}. If the response is an exception, that exception will
* be thrown instead.
*
* @return a non-null, possibly empty response
* @throws IOException an IO exception occurred during reading
* @throws InterruptedException an interrupted exception occurred during reading
*/
public String getResponse() throws IOException, InterruptedException {
// note: russound is inherently single threaded even though it accepts multiple connections
// if we have another thread sending a lot of commands (such as during startup), our response
// will not come in until the other commands have been processed. So we need a large wait
// time for it to be sent to us
final Object lastResponse = responses.poll(60, TimeUnit.SECONDS);
if (lastResponse instanceof String) {
return (String) lastResponse;
} else if (lastResponse instanceof IOException) {
throw (IOException) lastResponse;
} else if (lastResponse == null) {
throw new IOException("Didn't receive response in time");
} else {
return lastResponse.toString();
}
}
@Override
public void responseReceived(String response) throws InterruptedException {
responses.put(response);
}
@Override
public void responseException(IOException e) throws InterruptedException {
responses.put(e);
}
}

View File

@@ -0,0 +1,246 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandler;
import com.google.gson.Gson;
/**
* Represents the abstract base to a {@link BaseBridgeHandler} for common functionality to all Bridges. This abstract
* base provides management of the {@link AbstractRioProtocol}, parent {@link #bridgeStatusChanged(ThingStatusInfo)}
* event processing and the ability to get the current {@link SocketSession}.
* {@link #sendCommand(String)} and responses will be received on any {@link SocketSessionListener}
*
* @author Tim Roberts - Initial contribution
*/
public abstract class AbstractBridgeHandler<E extends AbstractRioProtocol> extends BaseBridgeHandler
implements RioCallbackHandler {
/**
* The protocol handler for this base
*/
private E protocolHandler;
/**
* Creates the handler from the given {@link Bridge}
*
* @param bridge a non-null {@link Bridge}
*/
protected AbstractBridgeHandler(Bridge bridge) {
super(bridge);
}
/**
* Sets a new {@link AbstractRioProtocol} as the current protocol handler. If one already exists, it will be
* disposed of first.
*
* @param newProtocolHandler a, possibly null, {@link AbstractRioProtocol}
*/
protected void setProtocolHandler(E newProtocolHandler) {
if (protocolHandler != null) {
protocolHandler.dispose();
}
protocolHandler = newProtocolHandler;
}
/**
* Get's the {@link AbstractRioProtocol} handler. May be null if none currently exists
*
* @return a {@link AbstractRioProtocol} handler or null if none exists
*/
protected E getProtocolHandler() {
return protocolHandler;
}
/**
* Overridden to simply get the protocol handler's {@link RioHandlerCallback}
*
* @return the {@link RioHandlerCallback} or null if not found
*/
@Override
public RioHandlerCallback getRioHandlerCallback() {
final E protocolHandler = getProtocolHandler();
return protocolHandler == null ? null : protocolHandler.getCallback();
}
/**
* Returns the {@link SocketSession} for this {@link Bridge}. The default implementation is to look in the parent
* {@link #getBridge()} for the {@link SocketSession}
*
* @return a {@link SocketSession} or null if none exists
*/
@SuppressWarnings("rawtypes")
public SocketSession getSocketSession() {
final Bridge bridge = getBridge();
if (bridge.getHandler() instanceof AbstractBridgeHandler) {
return ((AbstractBridgeHandler) bridge.getHandler()).getSocketSession();
}
return null;
}
/**
* Returns the {@link RioPresetsProtocol} for this {@link Bridge}. The default implementation is to look in the
* parent
* {@link #getPresetsProtocol()} for the {@link RioPresetsProtocol}
*
* @return a {@link RioPresetsProtocol} or null if none exists
*/
@SuppressWarnings("rawtypes")
public RioPresetsProtocol getPresetsProtocol() {
final Bridge bridge = getBridge();
if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) {
return ((AbstractBridgeHandler) bridge.getHandler()).getPresetsProtocol();
}
return null;
}
/**
* Returns the {@link RioSystemFavoritesProtocol} for this {@link Bridge}. The default implementation is to look in
* the
* parent
* {@link #getPresetsProtocol()} for the {@link RioSystemFavoritesProtocol}
*
* @return a {@link RioSystemFavoritesProtocol} or null if none exists
*/
@SuppressWarnings("rawtypes")
public RioSystemFavoritesProtocol getSystemFavoritesHandler() {
final Bridge bridge = getBridge();
if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) {
return ((AbstractBridgeHandler) bridge.getHandler()).getSystemFavoritesHandler();
}
return null;
}
/**
* Overrides the base to initialize or dispose the handler based on the parent bridge status changing. If offline,
* {@link #dispose()} will be called instead. We then try to reinitialize ourselves when the bridge goes back online
* via the {@link #retryBridge()} method.
*/
@Override
public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
reconnect();
} else {
disconnect();
}
super.bridgeStatusChanged(bridgeStatusInfo);
}
/**
* Creates an "{id:x, name: 'xx'}" json string from all {@link RioNamedHandler} for a specific class and sends that
* result to a channel id.
*
* @param gson a non-null {@link Gson} to use
* @param clazz a non-null class that the results will be for
* @param channelId a non-null, non-empty channel identifier to send the results to
* @throws IllegalArgumentException if any argument is null or empty
*/
protected <H extends RioNamedHandler> void refreshNamedHandler(Gson gson, Class<H> clazz, String channelId) {
if (gson == null) {
throw new IllegalArgumentException("gson cannot be null");
}
if (clazz == null) {
throw new IllegalArgumentException("clazz cannot be null");
}
if (StringUtils.isEmpty(channelId)) {
throw new IllegalArgumentException("channelId cannot be null or empty");
}
final List<IdName> ids = new ArrayList<>();
for (Thing thn : getThing().getThings()) {
if (thn.getStatus() == ThingStatus.ONLINE) {
final ThingHandler handler = thn.getHandler();
if (handler != null && handler.getClass().isAssignableFrom(clazz)) {
final RioNamedHandler namedHandler = (RioNamedHandler) handler;
if (namedHandler.getId() > 0) { // 0 returned when handler is initializing
ids.add(new IdName(namedHandler.getId(), namedHandler.getName()));
}
}
}
}
final String json = gson.toJson(ids);
updateState(channelId, new StringType(json));
}
/**
* Overrides the base method to remove any state linked to the {@lin ChannelUID} from the
* {@link StatefulHandlerCallback}
*/
@Override
public void channelUnlinked(ChannelUID channelUID) {
// Remove any state when unlinking (that way if it is relinked - we get it)
final RioHandlerCallback callback = getProtocolHandler().getCallback();
if (callback instanceof StatefulHandlerCallback) {
((StatefulHandlerCallback) callback).removeState(channelUID.getId());
}
super.channelUnlinked(channelUID);
}
/**
* Base method to reconnect the handler. The base implementation will simply {@link #disconnect()} then
* {@link #initialize()} the handler.
*/
protected void reconnect() {
disconnect();
initialize();
}
/**
* Base method to disconnect the handler. This implementation will simply call
* {@link #setProtocolHandler(AbstractRioProtocol)} to null.
*/
protected void disconnect() {
setProtocolHandler(null);
}
/**
* Overrides the dispose to call the {@link #disconnect()} method to disconnect the handler
*/
@Override
public void dispose() {
disconnect();
super.dispose();
}
/**
* Private class that simply stores an ID & Name. This class is solely used to create a json result like "{id:1,
* name:'stuff'}"
*
* @author Tim Roberts
*/
private class IdName {
@SuppressWarnings("unused")
private final int id;
@SuppressWarnings("unused")
private final String name;
public IdName(int id, String name) {
this.id = id;
this.name = name;
}
}
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import java.util.concurrent.CopyOnWriteArrayList;
import org.apache.commons.lang.StringUtils;
import org.openhab.core.types.State;
/**
* Abstract implementation of {@link RioHandlerCallback} that will provide listener services (adding/removing and firing
* of state)
*
* @author Tim Roberts - Initial contribution
*/
public abstract class AbstractRioHandlerCallback implements RioHandlerCallback {
/** Listener array */
private final CopyOnWriteArrayList<ListenerState> listeners = new CopyOnWriteArrayList<>();
/**
* Adds a listener to {@link #listeners} wrapping the listener in a {@link ListenerState}
*/
@Override
public void addListener(String channelId, RioHandlerCallbackListener listener) {
listeners.add(new ListenerState(channelId, listener));
}
/**
* Remove a listener from {@link #listeners} if the channelID matches
*/
@Override
public void removeListener(String channelId, RioHandlerCallbackListener listener) {
for (ListenerState listenerState : listeners) {
if (listenerState.channelId.equals(channelId) && listenerState.listener == listener) {
listeners.remove(listenerState);
}
}
}
/**
* Fires a stateUpdate message to all listeners for the channelId and state
*
* @param channelId a non-null, non-empty channelId
* @param state a non-null state
* @throws IllegalArgumentException if channelId is null or empty.
* @throws IllegalArgumentException if state is null
*/
protected void fireStateUpdated(String channelId, State state) {
if (StringUtils.isEmpty(channelId)) {
throw new IllegalArgumentException("channelId cannot be null or empty)");
}
if (state == null) {
throw new IllegalArgumentException("state cannot be null");
}
for (ListenerState listenerState : listeners) {
if (listenerState.channelId.equals(channelId)) {
listenerState.listener.stateUpdate(channelId, state);
}
}
}
/**
* Internal class used to associate a listener with a channel id
*
* @author Tim Roberts
*/
private class ListenerState {
/** The channelID */
private final String channelId;
/** The listener associated with it */
private final RioHandlerCallbackListener listener;
/**
* Create the listener state from the channelID and listener
*
* @param channelId the channelID
* @param listener the listener
*/
ListenerState(String channelId, RioHandlerCallbackListener listener) {
this.channelId = channelId;
this.listener = listener;
}
}
}

View File

@@ -0,0 +1,139 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import java.io.IOException;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.binding.russound.internal.rio.system.RioSystemHandler;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.State;
/**
* Defines the abstract base for a protocol handler. This base provides managment of the {@link SocketSession} and
* provides helper methods that will callback {@link RioHandlerCallback}
*
* @author Tim Roberts - Initial contribution
*/
public abstract class AbstractRioProtocol implements SocketSessionListener {
/**
* The {@link SocketSession} used by this protocol handler
*/
private final SocketSession session;
/**
* The {@link RioSystemHandler} to call back to update status and state
*/
private final RioHandlerCallback callback;
/**
* Constructs the protocol handler from given parameters and will add this handler as a
* {@link SocketSessionListener} to the specified {@link SocketSession} via
* {@link SocketSession#addListener(SocketSessionListener)}
*
* @param session a non-null {@link SocketSession} (may be connected or disconnected)
* @param callback a non-null {@link RioHandlerCallback} to update state and status
*/
protected AbstractRioProtocol(SocketSession session, RioHandlerCallback callback) {
if (session == null) {
throw new IllegalArgumentException("session cannot be null");
}
if (callback == null) {
throw new IllegalArgumentException("callback cannot be null");
}
this.session = session;
this.session.addListener(this);
this.callback = callback;
}
/**
* Sends the command and puts the thing into {@link ThingStatus#OFFLINE} if an IOException occurs
*
* @param command a non-null, non-empty command to send
*/
protected void sendCommand(String command) {
if (command == null) {
throw new IllegalArgumentException("command cannot be null");
}
try {
session.sendCommand(command);
} catch (IOException e) {
getCallback().statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Exception occurred sending command: " + e);
}
}
/**
* Updates the state via the {@link RioHandlerCallback#stateChanged(String, State)}
*
* @param channelId the channel id to update state
* @param newState the new state
*/
protected void stateChanged(String channelId, State newState) {
getCallback().stateChanged(channelId, newState);
}
/**
* Updates a property via the {@link RioHandlerCallback#setProperty(String, String)}
*
* @param propertyName a non-null, non-empty property name
* @param propertyValue a non-null, possibly empty property value
*/
protected void setProperty(String propertyName, String propertyValue) {
getCallback().setProperty(propertyName, propertyValue);
}
/**
* Updates the status via {@link RioHandlerCallback#statusChanged(ThingStatus, ThingStatusDetail, String)}
*
* @param status the new status
* @param statusDetail the status detail
* @param msg the status detail message
*/
protected void statusChanged(ThingStatus status, ThingStatusDetail statusDetail, String msg) {
getCallback().statusChanged(status, statusDetail, msg);
}
/**
* Disposes of the protocol by removing ourselves from listening to the socket via
* {@link SocketSession#removeListener(SocketSessionListener)}
*/
public void dispose() {
session.removeListener(this);
}
/**
* Implements the {@link SocketSessionListener#responseException(Exception)} to automatically take the thing offline
* via {@link RioHandlerCallback#statusChanged(ThingStatus, ThingStatusDetail, String)}
*
* @param e the exception
*/
@Override
public void responseException(IOException e) {
getCallback().statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Exception occurred reading from the socket: " + e);
}
/**
* Returns the {@link RioHandlerCallback} used by this protocol
*
* @return a non-null {@link RioHandlerCallback}
*/
public RioHandlerCallback getCallback() {
return callback;
}
}

View File

@@ -0,0 +1,150 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.binding.BaseThingHandler;
/**
* Represents the abstract base to a {@link BaseThingHandler} for common functionality to all Things. This abstract
* base provides management of the {@link AbstractRioProtocol}, parent {@link #bridgeStatusChanged(ThingStatusInfo)}
* event processing and the ability to get the current {@link SocketSession}.
* {@link #sendCommand(String)} and responses will be received on any {@link SocketSessionListener}
*
* @author Tim Roberts - Initial contribution
*/
public abstract class AbstractThingHandler<E extends AbstractRioProtocol> extends BaseThingHandler
implements RioCallbackHandler {
/**
* The protocol handler for this base
*/
private E protocolHandler;
/**
* Creates the handler from the given {@link Thing}
*
* @param thing a non-null {@link Thing}
*/
protected AbstractThingHandler(Thing thing) {
super(thing);
}
/**
* Sets a new {@link AbstractRioProtocol} as the current protocol handler. If one already exists, it will be
* disposed of first.
*
* @param newProtocolHandler a, possibly null, {@link AbstractRioProtocol}
*/
protected void setProtocolHandler(E newProtocolHandler) {
if (protocolHandler != null) {
protocolHandler.dispose();
}
protocolHandler = newProtocolHandler;
}
/**
* Get's the {@link AbstractRioProtocol} handler. May be null if none currently exists
*
* @return a {@link AbstractRioProtocol} handler or null if none exists
*/
protected E getProtocolHandler() {
return protocolHandler;
}
/**
* Overridden to simply get the protocol handler's {@link RioHandlerCallback}
*
* @return the {@link RioHandlerCallback} or null if not found
*/
@Override
public RioHandlerCallback getRioHandlerCallback() {
final E protocolHandler = getProtocolHandler();
return protocolHandler == null ? null : protocolHandler.getCallback();
}
/**
* Returns the {@link SocketSession} for this {@link Bridge}. The default implementation is to look in the parent
* {@link #getBridge()} for the {@link SocketSession}
*
* @return a {@link SocketSession} or null if none exists
*/
@SuppressWarnings("rawtypes")
protected SocketSession getSocketSession() {
final Bridge bridge = getBridge();
if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) {
return ((AbstractBridgeHandler) bridge.getHandler()).getSocketSession();
}
return null;
}
/**
* Overrides the base to initialize or dispose the handler based on the parent bridge status changing. If offline,
* {@link #dispose()} will be called instead. We then try to reinitialize ourselves when the bridge goes back online
* via the {@link #retryBridge()} method.
*/
@Override
public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
reconnect();
} else {
disconnect();
}
super.bridgeStatusChanged(bridgeStatusInfo);
}
/**
* Overrides the base method to remove any state linked to the {@lin ChannelUID} from the
* {@link StatefulHandlerCallback}
*/
@Override
public void channelUnlinked(ChannelUID channelUID) {
// Remove any state when unlinking (that way if it is relinked - we get it)
final RioHandlerCallback callback = getProtocolHandler().getCallback();
if (callback instanceof StatefulHandlerCallback) {
((StatefulHandlerCallback) callback).removeState(channelUID.getId());
}
super.channelUnlinked(channelUID);
}
/**
* Base method to reconnect the handler. The base implementation will simply {@link #disconnect()} then
* {@link #initialize()} the handler.
*/
protected void reconnect() {
disconnect();
initialize();
}
/**
* Base method to disconnect the handler. This implementation will simply call
* {@link #setProtocolHandler(AbstractRioProtocol)} to null.
*/
protected void disconnect() {
setProtocolHandler(null);
}
/**
* Overrides the dispose to call the {@link #disconnect()} method to disconnect the handler
*/
@Override
public void dispose() {
disconnect();
super.dispose();
}
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
/**
* This interface defines the methods that an implementing class needs to implement to provide the
* {@link RioHandlerCallback} used by the underlying protocol
*
* @author Tim Roberts - Initial contribution
*/
public interface RioCallbackHandler {
/**
* Get's the {@link RioHandlerCallback} for the underlying thing
*
* @return the {@link RioHandlerCallback} or null if none found
*/
RioHandlerCallback getRioHandlerCallback();
}

View File

@@ -0,0 +1,158 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.russound.internal.RussoundBindingConstants;
import org.openhab.core.thing.ThingTypeUID;
/**
* The class defines common constants ({@link ThingTypeUID} and channels), which are used across the rio binding
*
* @author Tim Roberts - Initial contribution
*/
@NonNullByDefault
public class RioConstants {
// BRIDGE TYPE IDS
public static final ThingTypeUID BRIDGE_TYPE_RIO = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "rio");
public static final ThingTypeUID BRIDGE_TYPE_CONTROLLER = new ThingTypeUID(RussoundBindingConstants.BINDING_ID,
"controller");
public static final ThingTypeUID THING_TYPE_ZONE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID, "zone");
public static final ThingTypeUID THING_TYPE_SOURCE = new ThingTypeUID(RussoundBindingConstants.BINDING_ID,
"source");
// the port number rio listens on
public static final int RIO_PORT = 9621;
// SYSTEM PROPERTIES
public static final String PROPERTY_SYSVERSION = "Firmware Version";
// SYSTEM CHANNELS
public static final String CHANNEL_SYSSTATUS = "status"; // readonly
public static final String CHANNEL_SYSLANG = "lang"; // read/write - english, chinese, russian
public static final String CHANNEL_SYSALLON = "allon"; // read/write - english, chinese, russian
public static final String CHANNEL_SYSCONTROLLERS = "controllers"; // json array [1,2,etc]
public static final String CHANNEL_SYSSOURCES = "sources"; // json array [{id: 1, name: xxx},{id:2, name: xxx}, etc]
// CONTROLLER PROPERTIES
public static final String PROPERTY_CTLTYPE = "Model Type";
public static final String PROPERTY_CTLIPADDRESS = "IP Address";
public static final String PROPERTY_CTLMACADDRESS = "MAC Address";
// CONTROLLER CHANNELS
public static final String CHANNEL_CTLZONES = "zones"; // json array [{id: 1, name: xxx},{id:2, name: xxx}, etc]
// ZONE CHANNELS
public static final String CHANNEL_ZONENAME = "name"; // 12 max
public static final String CHANNEL_ZONESOURCE = "source"; // 1-8 or 1-12
public static final String CHANNEL_ZONEBASS = "bass"; // -10 to 10
public static final String CHANNEL_ZONETREBLE = "treble"; // -10 to 10
public static final String CHANNEL_ZONEBALANCE = "balance"; // -10 to 10
public static final String CHANNEL_ZONELOUDNESS = "loudness"; // OFF/ON
public static final String CHANNEL_ZONETURNONVOLUME = "turnonvolume"; // 0 to 50
public static final String CHANNEL_ZONEDONOTDISTURB = "donotdisturb"; // OFF/ON/SLAVE
public static final String CHANNEL_ZONEPARTYMODE = "partymode"; // OFF/ON/MASTER
public static final String CHANNEL_ZONESTATUS = "status"; // OFF/ON/MASTER
public static final String CHANNEL_ZONEVOLUME = "volume"; // OFF/ON/MASTER
public static final String CHANNEL_ZONEMUTE = "mute"; // OFF/ON/MASTER
public static final String CHANNEL_ZONEPAGE = "page"; // OFF/ON/MASTER
public static final String CHANNEL_ZONERATING = "rating"; // OFF=Dislike, On=Like
public static final String CHANNEL_ZONESHAREDSOURCE = "sharedsource"; // OFF/ON/MASTER
public static final String CHANNEL_ZONESLEEPTIMEREMAINING = "sleeptimeremaining"; // OFF/ON/MASTER
public static final String CHANNEL_ZONELASTERROR = "lasterror"; // OFF/ON/MASTER
public static final String CHANNEL_ZONEENABLED = "enabled"; // OFF/ON/MASTER
public static final String CHANNEL_ZONEREPEAT = "repeat"; // OFF/ON/MASTER
public static final String CHANNEL_ZONESHUFFLE = "shuffle"; // OFF/ON/MASTER
public static final String CHANNEL_ZONESYSFAVORITES = "systemfavorites"; // json array
public static final String CHANNEL_ZONEFAVORITES = "zonefavorites"; // json array
public static final String CHANNEL_ZONEPRESETS = "presets"; // json array
// ZONE EVENT BASED
public static final String CHANNEL_ZONEKEYPRESS = "keypress";
public static final String CHANNEL_ZONEKEYRELEASE = "keyrelease";
public static final String CHANNEL_ZONEKEYHOLD = "keyhold";
public static final String CHANNEL_ZONEKEYCODE = "keycode";
public static final String CHANNEL_ZONEEVENT = "event";
// ZONE MEDIA CHANNELS
public static final String CHANNEL_ZONEMMINIT = "mminit";
public static final String CHANNEL_ZONEMMCONTEXTMENU = "mmcontextmenu";
// FAVORITE CHANNELS
public static final String CHANNEL_FAVNAME = "name";
public static final String CHANNEL_FAVVALID = "valid";
public static final String CHANNEL_FAVCMD = "cmd";
// FAVORITE COMMANDS
public static final String CMD_FAVSAVESYS = "savesystem";
public static final String CMD_FAVRESTORESYS = "restoresystem";
public static final String CMD_FAVDELETESYS = "deletesystem";
public static final String CMD_FAVSAVEZONE = "savezone";
public static final String CMD_FAVRESTOREZONE = "restorezone";
public static final String CMD_FAVDELETEZONE = "deletezone";
// BANK CHANNELS
public static final String CHANNEL_BANKNAME = "name";
// PRESET CHANNELS
public static final String CHANNEL_PRESETNAME = "name";
public static final String CHANNEL_PRESETVALID = "valid";
public static final String CHANNEL_PRESETCMD = "cmd";
// PRESET COMMANDS
public static final String CMD_PRESETSAVE = "save";
public static final String CMD_PRESETRESTORE = "restore";
public static final String CMD_PRESETDELETE = "delete";
// SOURCE PROPERTIES
public static final String PROPERTY_SOURCEIPADDRESS = "IP Address";
// SOURCE CHANNELS
public static final String CHANNEL_SOURCETYPE = "type";
public static final String CHANNEL_SOURCENAME = "name";
public static final String CHANNEL_SOURCECOMPOSERNAME = "composername";
public static final String CHANNEL_SOURCECHANNEL = "channel";
public static final String CHANNEL_SOURCECHANNELNAME = "channelname";
public static final String CHANNEL_SOURCEGENRE = "genre";
public static final String CHANNEL_SOURCEARTISTNAME = "artistname";
public static final String CHANNEL_SOURCEALBUMNAME = "albumname";
public static final String CHANNEL_SOURCECOVERARTURL = "coverarturl";
public static final String CHANNEL_SOURCEPLAYLISTNAME = "playlistname";
public static final String CHANNEL_SOURCESONGNAME = "songname";
public static final String CHANNEL_SOURCEMODE = "mode";
public static final String CHANNEL_SOURCESHUFFLEMODE = "shufflemode";
public static final String CHANNEL_SOURCEREPEATMODE = "repeatmode";
public static final String CHANNEL_SOURCERATING = "rating";
public static final String CHANNEL_SOURCEPROGRAMSERVICENAME = "programservicename";
public static final String CHANNEL_SOURCERADIOTEXT = "radiotext";
public static final String CHANNEL_SOURCERADIOTEXT2 = "radiotext2";
public static final String CHANNEL_SOURCERADIOTEXT3 = "radiotext3";
public static final String CHANNEL_SOURCERADIOTEXT4 = "radiotext4";
public static final String CHANNEL_SOURCEVOLUME = "volume";
public static final String CHANNEL_SOURCEBANKS = "banks";
// SOURCE MEDIA Channels
public static final String CHANNEL_SOURCEMMSCREEN = "mmscreen";
public static final String CHANNEL_SOURCEMMTITLE = "mmtitle";
public static final String CHANNEL_SOURCEMMMENU = "mmmenu";
public static final String CHANNEL_SOURCEMMATTR = "mmattr";
public static final String CHANNEL_SOURCEMMBUTTONOKTEXT = "mmmenubuttonoktext";
public static final String CHANNEL_SOURCEMMBUTTONBACKTEXT = "mmmenubuttonbacktext";
public static final String CHANNEL_SOURCEMMINFOTEXT = "mminfotext";
public static final String CHANNEL_SOURCEMMHELPTEXT = "mmhelptext";
public static final String CHANNEL_SOURCEMMTEXTFIELD = "mmtextfield";
}

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.State;
/**
*
* This interface is used to provide a callback mechanism between {@link AbstractRioProtocol} and the associated
* bridge/thing ({@link AbstractBridgeHandler} and {@link AbstractThingHandler}). This is necessary since the status and
* state of a bridge/thing is private and the protocol handler cannot access it directly.
*
* @author Tim Roberts - Initial contribution
*/
public interface RioHandlerCallback {
/**
* Callback to the bridge/thing to update the status of the bridge/thing.
*
* @param status a non-null {@link ThingStatus}
* @param detail a non-null {@link ThingStatusDetail}
* @param msg a possibly null, possibly empty message
*/
void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg);
/**
* Callback to the bridge/thing to update the state of a channel in the bridge/thing.
*
* @param channelId the non-null, non-empty channel id
* @param state the new non-null {@State}
*/
void stateChanged(String channelId, State state);
/**
* Callback to set a property for the thing
*
* @param propertyName a non-null, non-empty property name
* @param propertyValue a non-null, possibly empty property value
*/
void setProperty(String propertyName, String propertyValue);
/**
* Adds a listener to changes to the channelId
*
* @param channelId a non-null, non-empty channelID
* @param listener a non-null listener
* @throws IllegalArgumentException if channelId is null or empty
* @throws IllegalArgumentException if listener is null
*/
void addListener(String channelId, RioHandlerCallbackListener listener);
/**
* Removes the specified listener for the specified channel
*
* @param channelId a non-null, non-empty channelID
* @param listener a non-null listener
* @throws IllegalArgumentException if channelId is null or empty
* @throws IllegalArgumentException if listener is null
*/
void removeListener(String channelId, RioHandlerCallbackListener listener);
}

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import org.openhab.core.types.State;
/**
* Interface definition for any listener to state changes in a {@link RioHandlerCallback}
*
* @author Tim Roberts - Initial contribution
*/
public interface RioHandlerCallbackListener {
/**
* Called when the state has change
*
* @param channelId a non null, non-empty channel id that changed
* @param state a non-null new state
*/
void stateUpdate(String channelId, State state);
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
/**
* Interface for any handler that supports an identifier and name
*
* @author Tim Roberts - Initial contribution
*/
public interface RioNamedHandler {
/**
* Returns the ID of the handler
*
* @return the identifier of the handler
*/
int getId();
/**
* Returns the name of the handler
*
* @return
*/
String getName();
}

View File

@@ -0,0 +1,473 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
import org.openhab.binding.russound.internal.rio.models.RioPreset;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
/**
* This {@link AbstractRioProtocol} implementation provides the implementation for managing Russound bank presets.
* Since refreshing all 36 presets requires 72 calls to russound (for name/valid), we limit how often we can
* refresh to {@link #UPDATE_TIME_SPAN}. Presets are tracked by source ID and will only be valid if that source type is
* a tuner.
*
* @author Tim Roberts - Initial contribution
*/
public class RioPresetsProtocol extends AbstractRioProtocol {
// logger
private final Logger logger = LoggerFactory.getLogger(RioPresetsProtocol.class);
// helper names
private static final String PRESET_NAME = "name";
private static final String PRESET_VALID = "valid";
/**
* The pattern representing preset notifications
*/
private static final Pattern RSP_PRESETNOTIFICATION = Pattern
.compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
/**
* The pattern representing a source type notification
*/
private static final Pattern RSP_SRCTYPENOTIFICATION = Pattern.compile("^[SN] S\\[(\\d+)\\]\\.type=\"(.*)\"$");
/**
* All 36 presets represented by two dimensions - 8 source by 36 presets
*/
private final RioPreset[][] presets = new RioPreset[8][36];
/**
* Represents whether the source is a tuner or not
*/
private final boolean[] isTuner = new boolean[8];
/**
* The {@link Gson} used for JSON operations
*/
private final Gson gson;
/**
* The {@link ReentrantLock} used to control access to {@link #lastUpdateTime}
*/
private final Lock lastUpdateLock = new ReentrantLock();
/**
* The last time the specified source presets were updated via {@link #refreshPresets(Integer)}
*/
private final long[] lastUpdateTime = new long[8];
/**
* The minimum timespan between updates of source presets via {@link #refreshPresets(Integer)}
*/
private static final long UPDATE_TIME_SPAN = 60000;
/**
* The pattern to determine if the source type is a tuner
*/
private static final Pattern IS_TUNER = Pattern.compile("(?i)^.*AM.*FM.*TUNER.*$");
/**
* The list of listeners that will be called when system favorites have changed
*/
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
/**
* Constructs the preset protocol from the given session and callback. Note: the passed callback is not
* currently used
*
* @param session a non null {@link SocketSession} to use
* @param callback a non-null {@link RioHandlerCallback} to use
*/
public RioPresetsProtocol(SocketSession session, RioHandlerCallback callback) {
super(session, callback);
gson = GsonUtilities.createGson();
for (int s = 1; s <= 8; s++) {
sendCommand("GET S[" + s + "].type");
for (int x = 1; x <= 36; x++) {
presets[s - 1][x - 1] = new RioPreset(x);
}
}
}
/**
* Adds the specified listener to changes to presets
*
* @param listener a non-null listener to add
* @throws IllegalArgumentException if listener is null
*/
public void addListener(Listener listener) {
listeners.add(listener);
}
/**
* Removes the specified listener from change notifications
*
* @param listener a possibly null listener to remove (null is ignored)
* @return true if removed, false otherwise
*/
public boolean removeListener(Listener listener) {
return listeners.remove(listener);
}
/**
* Fires the presetsUpdate method on all listeners with the results of {@link #getJson()} for the given source
*
* @param sourceId a valid source identifier between 1 and 8
* @throws IllegalArgumentException if sourceId is < 1 or > 8
*/
private void fireUpdate(int sourceId) {
if (sourceId < 1 || sourceId > 8) {
throw new IllegalArgumentException("sourceId must be between 1 and 8");
}
final String json = getJson(sourceId);
for (Listener l : listeners) {
l.presetsUpdated(sourceId, json);
}
}
/**
* Helper method to request the specified presets id information (name/valid) for a given source. Please note that
* this does NOT change the {@link #lastUpdateTime}
*
* @param sourceId a source identifier between 1 and 8
* @param favIds a non-null, possibly empty list of system favorite ids to request (any id < 1 or > 32 will be
* ignored)
* @throws IllegalArgumentException if favIds is null
* @throws IllegalArgumentException if sourceId is < 1 or > 8
*/
private void requestPresets(int sourceId, List<RioPreset> presetIds) {
if (sourceId < 1 || sourceId > 8) {
throw new IllegalArgumentException("sourceId must be between 1 and 8");
}
if (presetIds == null) {
throw new IllegalArgumentException("presetIds must not be null");
}
for (RioPreset preset : presetIds) {
sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset() + "].valid");
sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset() + "].name");
}
}
/**
* Refreshes ALL presets for all sources. Simply calls {@link #refreshPresets(Integer)} with each source identifier
*/
public void refreshPresets() {
for (int sourceId = 1; sourceId <= 8; sourceId++) {
refreshPresets(sourceId);
}
}
/**
* Refreshes ALL presets for the given sourceId if they have not been refreshed within the last
* {@link #UPDATE_TIME_SPAN}. This method WILL change the {@link #lastUpdateTime}. No calls will be made if the
* source type is not a tuner (however the {@link #lastUpdateTime} will be reset).
*
* @param sourceId a source identifier between 1 and 8
* @throws IllegalArgumentException if sourceId is < 1 or > 8
*/
public void refreshPresets(Integer sourceId) {
if (sourceId < 1 || sourceId > 8) {
throw new IllegalArgumentException("sourceId must be between 1 and 8");
}
lastUpdateLock.lock();
try {
final long now = System.currentTimeMillis();
if (now > lastUpdateTime[sourceId - 1] + UPDATE_TIME_SPAN) {
lastUpdateTime[sourceId - 1] = now;
if (isTuner[sourceId - 1]) {
for (int x = 1; x <= 36; x++) {
final RioPreset preset = presets[sourceId - 1][x - 1];
sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset()
+ "].valid");
sendCommand("GET S[" + sourceId + "].B[" + preset.getBank() + "].P[" + preset.getBankPreset()
+ "].name");
}
}
}
} finally {
lastUpdateLock.unlock();
}
}
/**
* Returns the JSON representation of the presets for the sourceId and their state. If the sourceId does not
* represent a tuner, then an empty array JSON representation ("[]") will be returned.
*
* @return A non-null, non-empty JSON representation of {@link #_systemFavorites}
*/
public String getJson(int source) {
if (!isTuner[source - 1]) {
return "[]";
}
final List<RioPreset> validPresets = new ArrayList<>();
for (final RioPreset preset : presets[source - 1]) {
if (preset.isValid()) {
validPresets.add(preset);
}
}
return gson.toJson(validPresets);
}
/**
* Sets a zone preset. NOTE: at this time, only a single preset can be represented in the presetJson. Having more
* than one preset saved to the same underlying channel causes the russound system to become a little unstable. This
* method will save the preset if the status is changed from not valid to valid or if the name is simply changing on
* a currently valid preset. The preset will be deleted if status is changed from valid to not valid. When saving a
* preset and the name is not specified, the russound system will automatically assign a name equal to the channel
* being played.
*
* @param controller a controller between 1 and 6
* @param zone a zone between 1 and 8
* @param source a source between 1 and 8
* @param presetJson the possibly empty, possibly null JSON representation of the preset
* @throws IllegalArgumentException if controller is < 1 or > 6
* @throws IllegalArgumentException if zone is < 1 or > 8
* @throws IllegalArgumentException if source is < 1 or > 8
* @throws IllegalArgumentException if presetJson contains more than one preset
*/
public void setZonePresets(int controller, int zone, int source, String presetJson) {
if (controller < 1 || controller > 6) {
throw new IllegalArgumentException("Controller must be between 1 and 6");
}
if (zone < 1 || zone > 8) {
throw new IllegalArgumentException("Zone must be between 1 and 8");
}
if (source < 1 || source > 8) {
throw new IllegalArgumentException("Source must be between 1 and 8");
}
if (StringUtils.isEmpty(presetJson)) {
return;
}
final List<RioPreset> updatePresetIds = new ArrayList<>();
try {
final RioPreset[] newPresets = gson.fromJson(presetJson, RioPreset[].class);
// Keeps from screwing up the system if you set a bunch of presets to the same playing
if (newPresets.length > 1) {
throw new IllegalArgumentException("Can only save a single preset at a time");
}
for (int x = newPresets.length - 1; x >= 0; x--) {
final RioPreset preset = newPresets[x];
if (preset == null) {
continue;// caused by {id,valid,name},,{id,valid,name}
}
final int presetId = preset.getId();
if (presetId < 1 || presetId > 36) {
logger.debug("Invalid preset id (not between 1 and 36) - ignoring: {}:{}", presetId, presetJson);
} else {
final RioPreset myPreset = presets[source][presetId];
final boolean presetValid = preset.isValid();
final String presetName = preset.getName();
// re-retrieve to see if the save/delete worked (saving on a zone that's off - valid won't be set to
// true)
if (!StringUtils.equals(myPreset.getName(), presetName) || myPreset.isValid() != presetValid) {
myPreset.setName(presetName);
myPreset.setValid(presetValid);
if (presetValid) {
if (StringUtils.isEmpty(presetName)) {
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset " + presetId);
} else {
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!savePreset \"" + presetName
+ "\" " + presetId);
}
updatePresetIds.add(preset);
} else {
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!deletePreset " + presetId);
}
}
}
}
} catch (JsonSyntaxException e) {
logger.debug("Invalid JSON: {}", e.getMessage(), e);
}
// Invalid the presets we updated
requestPresets(source, updatePresetIds);
// Refresh our channel since 'presetJson' occupies it right now
fireUpdate(source);
}
/**
* Handles any system notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
void handlePresetNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 5) {
try {
final int source = Integer.parseInt(m.group(1));
if (source >= 1 && source <= 8) {
final int bank = Integer.parseInt(m.group(2));
if (bank >= 1 && bank <= 6) {
final int preset = Integer.parseInt(m.group(3));
if (preset >= 1 && preset <= 6) {
final String key = m.group(4).toLowerCase();
final String value = m.group(5);
final RioPreset rioPreset = presets[source - 1][(bank - 1) * 6 + preset - 1];
switch (key) {
case PRESET_NAME:
rioPreset.setName(value);
fireUpdate(source);
break;
case PRESET_VALID:
rioPreset.setValid(!"false".equalsIgnoreCase(value));
fireUpdate(source);
break;
default:
logger.warn("Unknown preset notification: '{}'", resp);
break;
}
} else {
logger.debug("Preset ID must be between 1 and 6: {}", resp);
}
} else {
logger.debug("Bank ID must be between 1 and 6: {}", resp);
}
} else {
logger.debug("Source ID must be between 1 and 8: {}", resp);
}
} catch (NumberFormatException e) {
logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
}
} else {
logger.warn("Invalid Preset Notification: '{}')", resp);
}
}
/**
* Handles any preset notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
private void handlerSourceTypeNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 2) {
try {
final int sourceId = Integer.parseInt(m.group(1));
if (sourceId >= 1 && sourceId <= 8) {
final String sourceType = m.group(2);
final Matcher matcher = IS_TUNER.matcher(sourceType);
final boolean srcIsTuner = matcher.matches();
if (srcIsTuner != isTuner[sourceId - 1]) {
isTuner[sourceId - 1] = srcIsTuner;
if (srcIsTuner) {
// force a refresh on the source
lastUpdateTime[sourceId - 1] = 0;
refreshPresets(sourceId);
} else {
for (int p = 0; p < 36; p++) {
presets[sourceId - 1][p].setValid(false);
presets[sourceId - 1][p].setName(null);
}
}
fireUpdate(sourceId);
}
} else {
logger.debug("Source is not between 1 and 8, Response: {}", resp);
}
} catch (NumberFormatException e) {
logger.warn("Invalid Preset Notification (source/bank/preset not a parsable integer): '{}')", resp);
}
} else {
logger.warn("Invalid Preset Notification: '{}')", resp);
}
}
/**
* Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
* russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
*
* @param a possibly null, possibly empty response
*/
@Override
public void responseReceived(String response) {
if (StringUtils.isEmpty(response)) {
return;
}
Matcher m = RSP_PRESETNOTIFICATION.matcher(response);
if (m.matches()) {
handlePresetNotification(m, response);
}
m = RSP_SRCTYPENOTIFICATION.matcher(response);
if (m.matches()) {
handlerSourceTypeNotification(m, response);
}
}
/**
* Defines the listener implementation to list for preset updates
*
* @author Tim Roberts
*
*/
public interface Listener {
/**
* Called when presets have changed for a specific sourceId. The jsonString will contain the current
* representation of all valid presets for the source.
*
* @param sourceId a source identifier between 1 and 8
* @param jsonString a non-null, non-empty json representation of {@link RioPreset}
*/
void presetsUpdated(int sourceId, String jsonString);
}
}

View File

@@ -0,0 +1,340 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
import org.openhab.binding.russound.internal.rio.models.RioFavorite;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
/**
* This {@link AbstractRioProtocol} implementation provides the implementation for managing Russound system favorites.
* Since refreshing all 32 system favorites requires 64 calls to russound (for name/valid), we limit how often we can
* refresh to {@link #UPDATE_TIME_SPAN}.
*
* @author Tim Roberts - Initial contribution
*/
public class RioSystemFavoritesProtocol extends AbstractRioProtocol {
// logger
private final Logger logger = LoggerFactory.getLogger(RioSystemFavoritesProtocol.class);
// Helper names in the protocol
private static final String FAV_NAME = "name";
private static final String FAV_VALID = "valid";
/**
* The pattern representing system favorite notifications
*/
private static final Pattern RSP_SYSTEMFAVORITENOTIFICATION = Pattern
.compile("(?i)^[SN] System.favorite\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
/**
* The current state of all 32 system favorites
*/
private final RioFavorite[] systemFavorites = new RioFavorite[32];
/**
* The {@link Gson} used for all JSON operations
*/
private final Gson gson;
/**
* The {@link ReentrantLock} used to control access to {@link #lastUpdateTime}
*/
private final Lock lastUpdateLock = new ReentrantLock();
/**
* The last time we did a full refresh of system favorites via {@link #refreshSystemFavorites()}
*/
private long lastUpdateTime;
/**
* The minimum timespan between full refreshes of system favorites (via {@link #refreshSystemFavorites()})
*/
private static final long UPDATE_TIME_SPAN = 60000;
/**
* The list of listeners that will be called when system favorites have changed
*/
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
/**
* Constructs the system favorite protocol from the given session and callback. Note: the passed callback is not
* currently used
*
* @param session a non null {@link SocketSession} to use
* @param callback a non-null {@link RioHandlerCallback} to use
*/
public RioSystemFavoritesProtocol(SocketSession session, RioHandlerCallback callback) {
super(session, callback);
gson = GsonUtilities.createGson();
for (int x = 1; x <= 32; x++) {
systemFavorites[x - 1] = new RioFavorite(x);
}
}
/**
* Adds the specified listener to changes in system favorites
*
* @param listener a non-null listener to add
* @throws IllegalArgumentException if listener is null
*/
public void addListener(Listener listener) {
if (listener == null) {
throw new IllegalArgumentException("listener cannot be null");
}
listeners.add(listener);
}
/**
* Removes the specified listener from change notifications
*
* @param listener a possibly null listener to remove (null is ignored)
* @return true if removed, false otherwise
*/
public boolean removeListener(Listener listener) {
return listeners.remove(listener);
}
/**
* Fires the systemFavoritesUpdated method on all listeners with the results of {@link #getJson()}
*/
private void fireUpdate() {
final String json = getJson();
for (Listener l : listeners) {
l.systemFavoritesUpdated(json);
}
}
/**
* Helper method to request the specified system favorite id information (name/valid). Please note that this does
* NOT change the {@link #lastUpdateTime}
*
* @param favIds a non-null, possibly empty list of system favorite ids to request (any id < 1 or > 32 will be
* ignored)
* @throws IllegalArgumentException if favIds is null
*/
private void requestSystemFavorites(List<Integer> favIds) {
if (favIds == null) {
throw new IllegalArgumentException("favIds cannot be null");
}
for (final Integer favId : favIds) {
if (favId >= 1 && favId <= 32) {
sendCommand("GET System.favorite[" + favId + "].name");
sendCommand("GET System.favorite[" + favId + "].valid");
}
}
}
/**
* Refreshes ALL system favorites if they have not been refreshed within the last
* {@link #UPDATE_TIME_SPAN}. This method WILL change the {@link #lastUpdateTime}
*/
public void refreshSystemFavorites() {
lastUpdateLock.lock();
try {
final long now = System.currentTimeMillis();
if (now > lastUpdateTime + UPDATE_TIME_SPAN) {
lastUpdateTime = now;
for (int x = 1; x <= 32; x++) {
sendCommand("GET System.favorite[" + x + "].valid");
sendCommand("GET System.favorite[" + x + "].name");
}
}
} finally {
lastUpdateLock.unlock();
}
}
/**
* Returns the JSON representation of all the system favorites and their state.
*
* @return A non-null, non-empty JSON representation of {@link #systemFavorites}
*/
public String getJson() {
final List<RioFavorite> favs = new ArrayList<>();
for (final RioFavorite fav : systemFavorites) {
if (fav.isValid()) {
favs.add(fav);
}
}
return gson.toJson(favs);
}
/**
* Sets the system favorites for a controller/zone. For each system favorite found in the favJson parameter, this
* method will either save the system favorite (if it's status changed from not valid to valid) or save the system
* favorite name (if only the name changed) or delete the system favorite (if status changed from valid to invalid).
*
* @param controller the controller number between 1 and 6
* @param zone the zone number between 1 and 8
* @param favJson the possibly empty, possibly null JSON representation of system favorites
* @throws IllegalArgumentException if controller is < 1 or > 6
* @throws IllegalArgumentException if zone is < 1 or > 8
*/
public void setSystemFavorites(int controller, int zone, String favJson) {
if (controller < 1 || controller > 6) {
throw new IllegalArgumentException("Controller must be between 1 and 6");
}
if (zone < 1 || zone > 8) {
throw new IllegalArgumentException("Zone must be between 1 and 8");
}
if (StringUtils.isEmpty(favJson)) {
return;
}
final List<Integer> updateFavIds = new ArrayList<>();
try {
final RioFavorite[] favs;
favs = gson.fromJson(favJson, RioFavorite[].class);
for (int x = favs.length - 1; x >= 0; x--) {
final RioFavorite fav = favs[x];
if (fav == null) {
continue; // caused by {id,valid,name},,{id,valid,name}
}
final int favId = fav.getId();
if (favId < 1 || favId > 32) {
logger.debug("Invalid favorite id (not between 1 and 32) - ignoring: {}:{}", favId, favJson);
} else {
final RioFavorite myFav = systemFavorites[favId - 1];
final boolean favValid = fav.isValid();
final String favName = fav.getName();
// re-retrieve to see if the save/delete worked (saving on a zone that's off - valid won't be set to
// true)
if (myFav.isValid() != favValid) {
myFav.setValid(favValid);
if (favValid) {
myFav.setName(favName);
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!saveSystemFavorite \"" + favName
+ "\" " + favId);
updateFavIds.add(favId);
} else {
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!deleteSystemFavorite " + favId);
}
} else if (!StringUtils.equals(myFav.getName(), favName)) {
myFav.setName(favName);
sendCommand("SET System.favorite[" + favId + "]." + FAV_NAME + "=\"" + favName + "\"");
}
}
}
} catch (JsonSyntaxException e) {
logger.debug("Invalid JSON: {}", e.getMessage(), e);
}
// Refresh the favorites that changed (verifies if the favorite was actually saved)
requestSystemFavorites(updateFavIds);
// Refresh any listeners immediately to reset the channel
fireUpdate();
}
/**
* Handles any system notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
private void handleSystemFavoriteNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 3) {
try {
final int favoriteId = Integer.parseInt(m.group(1));
if (favoriteId >= 1 && favoriteId <= 32) {
final RioFavorite fav = systemFavorites[favoriteId - 1];
final String key = m.group(2).toLowerCase();
final String value = m.group(3);
switch (key) {
case FAV_NAME:
fav.setName(value);
fireUpdate();
break;
case FAV_VALID:
fav.setValid(!"false".equalsIgnoreCase(value));
fireUpdate();
break;
default:
logger.warn("Unknown system favorite notification: '{}'", resp);
break;
}
} else {
logger.warn("Invalid System Favorite Notification (favorite < 1 or > 32): '{}')", resp);
}
} catch (NumberFormatException e) {
logger.warn("Invalid System Favorite Notification (favorite not a parsable integer): '{}')", resp);
}
} else {
logger.warn("Invalid System Notification response: '{}'", resp);
}
}
/**
* Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
* russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
*
* @param a possibly null, possibly empty response
*/
@Override
public void responseReceived(String response) {
if (StringUtils.isEmpty(response)) {
return;
}
final Matcher m = RSP_SYSTEMFAVORITENOTIFICATION.matcher(response);
if (m.matches()) {
handleSystemFavoriteNotification(m, response);
}
}
/**
* Defines the listener implementation to list for system favorite updates
*
* @author Tim Roberts
*
*/
public interface Listener {
/**
* Called when system favorites have changed. The jsonString will contain the current representation of all
* valid system favorites.
*
* @param jsonString a non-null, non-empty json representation of {@link RioFavorite}
*/
void systemFavoritesUpdated(String jsonString);
}
}

View File

@@ -0,0 +1,156 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import org.apache.commons.lang.StringUtils;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.State;
/**
* Defines an implementation of {@link RioHandlerCallback} that will remember the last state
* for an channelId and suppress the callback if the state hasn't changed
*
* @author Tim Roberts - Initial contribution
*/
public class StatefulHandlerCallback implements RioHandlerCallback {
/** The wrapped callback */
private final RioHandlerCallback wrappedCallback;
/** The state by channel id */
private final Map<String, State> state = new ConcurrentHashMap<>();
private final Lock statusLock = new ReentrantLock();
private ThingStatus lastThingStatus = null;
private ThingStatusDetail lastThingStatusDetail = null;
/**
* Create the callback from the other {@link RioHandlerCallback}
*
* @param wrappedCallback a non-null {@link RioHandlerCallback}
* @throws IllegalArgumentException if wrappedCallback is null
*/
public StatefulHandlerCallback(RioHandlerCallback wrappedCallback) {
if (wrappedCallback == null) {
throw new IllegalArgumentException("wrappedCallback cannot be null");
}
this.wrappedCallback = wrappedCallback;
}
/**
* Overrides the status changed to simply call the {@link #wrappedCallback}
*
* @param status the new status
* @param detail the new detail
* @param msg the new message
*/
@Override
public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) {
statusLock.lock();
try {
// Simply return we match the last status change (prevents loops if changing to the same status)
if (status == lastThingStatus && detail == lastThingStatusDetail) {
return;
}
lastThingStatus = status;
lastThingStatusDetail = detail;
} finally {
statusLock.unlock();
}
// If we got this far - call the underlying one
wrappedCallback.statusChanged(status, detail, msg);
}
/**
* Overrides the state changed to determine if the state is new or changed and then
* to call the {@link #wrappedCallback} if it has
*
* @param channelId the channel id that changed
* @param newState the new state
*/
@Override
public void stateChanged(String channelId, State newState) {
if (StringUtils.isEmpty(channelId)) {
return;
}
final State oldState = state.get(channelId);
// If both null OR the same value (enums), nothing changed
if (oldState == newState) {
return;
}
// If they are equal - nothing changed
if (oldState != null && oldState.equals(newState)) {
return;
}
// Something changed - save the new state and call the underlying wrapped
state.put(channelId, newState);
wrappedCallback.stateChanged(channelId, newState);
}
/**
* Removes the state associated with the channel id. If the channelid
* doesn't exist (or is null or is empty), this method will do nothing.
*
* @param channelId the channel id to remove state
*/
public void removeState(String channelId) {
if (StringUtils.isEmpty(channelId)) {
return;
}
state.remove(channelId);
}
/**
* Overrides the set property to simply call the {@link #wrappedCallback}
*
* @param propertyName a non-null, non-empty property name
* @param propertyValue a non-null, possibly empty property value
*/
@Override
public void setProperty(String propertyName, String propertyValue) {
wrappedCallback.setProperty(propertyName, propertyValue);
}
/**
* Returns teh current state for the property
*
* @param propertyName a possibly null, possibly empty property name
* @return the {@link State} for the property or null if not found (or property name is null/empty)
*/
public State getProperty(String propertyName) {
return state.get(propertyName);
}
@Override
public void addListener(String channelId, RioHandlerCallbackListener listener) {
wrappedCallback.addListener(channelId, listener);
}
@Override
public void removeListener(String channelId, RioHandlerCallbackListener listener) {
wrappedCallback.removeListener(channelId, listener);
}
}

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.controller;
/**
* Configuration class for the {@link RioControllerHandler}
*
* @author Tim Roberts - Initial contribution
*/
public class RioControllerConfig {
/**
* Constant defined for the "controller" configuration field
*/
public static final String CONTROLLER = "controller";
/**
* ID of the controller
*/
private int controller;
/**
* Gets the controller identifier
*
* @return the controller identifier
*/
public int getController() {
return controller;
}
/**
* Sets the controller identifier
*
* @param controller the controller identifier
*/
public void setController(int controller) {
this.controller = controller;
}
}

View File

@@ -0,0 +1,246 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.controller;
import java.util.concurrent.atomic.AtomicInteger;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.rio.AbstractBridgeHandler;
import org.openhab.binding.russound.internal.rio.AbstractRioHandlerCallback;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
import org.openhab.binding.russound.internal.rio.RioHandlerCallbackListener;
import org.openhab.binding.russound.internal.rio.RioNamedHandler;
import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback;
import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
import org.openhab.binding.russound.internal.rio.source.RioSourceHandler;
import org.openhab.binding.russound.internal.rio.system.RioSystemHandler;
import org.openhab.binding.russound.internal.rio.zone.RioZoneHandler;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import com.google.gson.Gson;
/**
* The bridge handler for a Russound Controller. A controller provides access to sources ({@link RioSourceHandler}) and
* zones ({@link RioZoneHandler}). This
* implementation must be attached to a {@link RioSystemHandler} bridge.
*
* @author Tim Roberts - Initial contribution
*/
public class RioControllerHandler extends AbstractBridgeHandler<RioControllerProtocol> implements RioNamedHandler {
/**
* The controller identifier of this instance (between 1-6)
*/
private final AtomicInteger controller = new AtomicInteger(0);
/**
* {@link Gson} used for JSON operations
*/
private final Gson gson = GsonUtilities.createGson();
/**
* Callback listener to use when zone name changes - will call {@link #refreshNamedHandler(Gson, Class, String)} to
* refresh the {@link RioConstants#CHANNEL_CTLZONES} channel
*/
private final RioHandlerCallbackListener handlerCallbackListener = new RioHandlerCallbackListener() {
@Override
public void stateUpdate(String channelId, State state) {
refreshNamedHandler(gson, RioZoneHandler.class, RioConstants.CHANNEL_CTLZONES);
}
};
/**
* Constructs the handler from the {@link Bridge}
*
* @param bridge a non-null {@link Bridge} the handler is for
*/
public RioControllerHandler(Bridge bridge) {
super(bridge);
}
/**
* Returns the controller identifier
*
* @return the controller identifier
*/
@Override
public int getId() {
return controller.get();
}
/**
* Returns the controller name
*
* @return a non-empty, non-null controller name
*/
@Override
public String getName() {
return "Controller " + getId();
}
/**
* {@inheritDoc}
*
* Handles commands to specific channels. The only command this handles is a {@link RefreshType} and that's handled
* by {{@link #handleRefresh(String)}
*/
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
handleRefresh(channelUID.getId());
return;
}
}
/**
* Method that handles the {@link RefreshType} command specifically.
*
* @param id a non-null, possibly empty channel id to refresh
*/
private void handleRefresh(String id) {
if (id.equals(RioConstants.CHANNEL_CTLZONES)) {
refreshNamedHandler(gson, RioZoneHandler.class, RioConstants.CHANNEL_CTLZONES);
}
// Can't refresh any others...
}
/**
* Initializes the bridge. Confirms the configuration is valid and that our parent bridge is a
* {@link RioSystemHandler}. Once validated, a {@link RioControllerProtocol} is set via
* {@link #setProtocolHandler(RioControllerProtocol)} and the bridge comes online.
*/
@Override
public void initialize() {
final Bridge bridge = getBridge();
if (bridge == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Cannot be initialized without a bridge");
return;
}
if (bridge.getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
return;
}
final ThingHandler handler = bridge.getHandler();
if (handler == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No handler specified (null) for the bridge!");
return;
}
if (!(handler instanceof RioSystemHandler)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Controller must be attached to a system bridge: " + handler.getClass());
return;
}
final RioControllerConfig config = getThing().getConfiguration().as(RioControllerConfig.class);
if (config == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing");
return;
}
final int configController = config.getController();
if (configController < 1 || configController > 8) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Controller must be between 1 and 8: " + configController);
return;
}
controller.set(configController);
// Get the socket session from the
final SocketSession socketSession = getSocketSession();
if (socketSession == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "No socket session found");
return;
}
if (getProtocolHandler() != null) {
getProtocolHandler().dispose();
}
setProtocolHandler(new RioControllerProtocol(configController, socketSession,
new StatefulHandlerCallback(new AbstractRioHandlerCallback() {
@Override
public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) {
updateStatus(status, detail, msg);
}
@Override
public void stateChanged(String channelId, State state) {
updateState(channelId, state);
fireStateUpdated(channelId, state);
}
@Override
public void setProperty(String propertyName, String property) {
getThing().setProperty(propertyName, property);
}
})));
updateStatus(ThingStatus.ONLINE);
getProtocolHandler().postOnline();
refreshNamedHandler(gson, RioZoneHandler.class, RioConstants.CHANNEL_CTLZONES);
}
/**
* Overrides the base to call {@link #childChanged(ThingHandler)} to recreate the zone names
*/
@Override
public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
childChanged(childHandler, true);
}
/**
* Overrides the base to call {@link #childChanged(ThingHandler)} to recreate the zone names
*/
@Override
public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
childChanged(childHandler, false);
}
/**
* Helper method to recreate the {@link RioConstants#CHANNEL_CTLZONES} channel
*
* @param childHandler a non-null child handler that changed
* @param added true if the handler was added, false otherwise
* @throw IllegalArgumentException if childHandler is null
*/
private void childChanged(ThingHandler childHandler, boolean added) {
if (childHandler == null) {
throw new IllegalArgumentException("childHandler cannot be null");
}
if (childHandler instanceof RioZoneHandler) {
final RioHandlerCallback callback = ((RioZoneHandler) childHandler).getRioHandlerCallback();
if (callback != null) {
if (added) {
callback.addListener(RioConstants.CHANNEL_ZONENAME, handlerCallbackListener);
} else {
callback.removeListener(RioConstants.CHANNEL_ZONENAME, handlerCallbackListener);
}
}
refreshNamedHandler(gson, RioZoneHandler.class, RioConstants.CHANNEL_CTLZONES);
}
}
}

View File

@@ -0,0 +1,169 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.controller;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.binding.russound.internal.rio.AbstractRioProtocol;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This is the protocol handler for the Russound controller. This handler will issue the protocol commands and will
* process the responses from the Russound system.
*
* @author Tim Roberts - Initial contribution
*/
class RioControllerProtocol extends AbstractRioProtocol {
// logger
private final Logger logger = LoggerFactory.getLogger(RioControllerProtocol.class);
/**
* The controller identifier
*/
private final int controller;
// Protocol constants
private static final String CTL_TYPE = "type";
private static final String CTL_IPADDRESS = "ipaddress";
private static final String CTL_MACADDRESS = "macaddress";
// Response patterns
private static final Pattern RSP_CONTROLLERNOTIFICATION = Pattern
.compile("(?i)^[SN] C\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
/**
* Constructs the protocol handler from given parameters
*
* @param controller the controller identifier
* @param session a non-null {@link SocketSession} (may be connected or disconnected)
* @param callback a non-null {@link RioHandlerCallback} to callback
*/
RioControllerProtocol(int controller, SocketSession session, RioHandlerCallback callback) {
super(session, callback);
this.controller = controller;
}
/**
* Helper method to issue post online commands
*/
void postOnline() {
refreshControllerType();
refreshControllerIpAddress();
refreshControllerMacAddress();
}
/**
* Issues a get command for the controller given the keyname
*
* @param keyName a non-null, non-empty keyname to get
* @throws IllegalArgumentException if name is null or an empty string
*/
private void refreshControllerKey(String keyName) {
if (keyName == null || keyName.trim().length() == 0) {
throw new IllegalArgumentException("keyName cannot be null or empty");
}
sendCommand("GET C[" + controller + "]." + keyName);
}
/**
* Refreshes the controller IP address
*/
void refreshControllerIpAddress() {
refreshControllerKey(CTL_IPADDRESS);
}
/**
* Refreshes the controller MAC address
*/
void refreshControllerMacAddress() {
refreshControllerKey(CTL_MACADDRESS);
}
/**
* Refreshes the controller Model Type
*/
void refreshControllerType() {
refreshControllerKey(CTL_TYPE);
}
/**
* Handles any controller notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
private void handleControllerNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 3) {
try {
final int notifyController = Integer.parseInt(m.group(1));
if (notifyController != controller) {
return;
}
final String key = m.group(2).toLowerCase();
final String value = m.group(3);
switch (key) {
case CTL_TYPE:
setProperty(RioConstants.PROPERTY_CTLTYPE, value);
break;
case CTL_IPADDRESS:
setProperty(RioConstants.PROPERTY_CTLIPADDRESS, value);
break;
case CTL_MACADDRESS:
setProperty(RioConstants.PROPERTY_CTLMACADDRESS, value);
break;
default:
logger.debug("Unknown controller notification: '{}'", resp);
break;
}
} catch (NumberFormatException e) {
logger.debug("Invalid Controller Notification (controller not a parsable integer): '{}')", resp);
}
} else {
logger.debug("Invalid Controller Notification response: '{}'", resp);
}
}
/**
* Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
* russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
*
* @param a possibly null, possibly empty response
*/
@Override
public void responseReceived(String response) {
if (StringUtils.isEmpty(response)) {
return;
}
final Matcher m = RSP_CONTROLLERNOTIFICATION.matcher(response);
if (m.matches()) {
handleControllerNotification(m, response);
return;
}
}
}

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.models;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
/**
* A GSON {@link TypeAdapter} that will properly write/read {@link AtomicReference} strings
*
* @author Tim Roberts - Initial contribution
*/
public class AtomicStringTypeAdapter extends TypeAdapter<AtomicReference<String>> {
/**
* Overriden to read the string from the {@link JsonReader} and create an
* {@link AtomicReference} from it
*/
@Override
public AtomicReference<String> read(JsonReader in) throws IOException {
AtomicReference<String> value = null;
JsonParser jsonParser = new JsonParser();
JsonElement je = jsonParser.parse(in);
if (je instanceof JsonPrimitive) {
value = new AtomicReference<>();
value.set(((JsonPrimitive) je).getAsString());
} else if (je instanceof JsonObject) {
JsonObject jsonObject = (JsonObject) je;
value = new AtomicReference<>();
value.set(jsonObject.get("value").getAsString());
}
return value;
}
/**
* Overridden to write out the underlying string
*/
@Override
public void write(JsonWriter out, AtomicReference<String> value) throws IOException {
if (value != null) {
out.value(value.get());
}
}
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.models;
import java.util.concurrent.atomic.AtomicReference;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
/**
* Utility class for common GSON related items
*
* @author Tim Roberts - Initial contribution
*/
public class GsonUtilities {
/**
* Utility method to create a standard {@link Gson} for the system. The standard GSon will register the
* {@link AtomicStringTypeAdapter} and the various serializers (Presets, Banks, Favorites)
*
* @return a non-null {@link Gson}
*/
public static Gson createGson() {
final GsonBuilder gs = new GsonBuilder();
gs.registerTypeAdapter(new TypeToken<AtomicReference<String>>() {
}.getType(), new AtomicStringTypeAdapter());
gs.registerTypeAdapter(RioPreset.class, new RioPresetSerializer());
gs.registerTypeAdapter(RioBank.class, new RioBankSerializer());
gs.registerTypeAdapter(RioFavorite.class, new RioFavoriteSerializer());
return gs.create();
}
}

View File

@@ -0,0 +1,87 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.models;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.lang.StringUtils;
/**
* Simple model of a RIO Bank and it's attributes. Please note this class is used to serialize/deserialize to JSON.
*
* @author Tim Roberts - Initial contribution
*/
public class RioBank {
/**
* The Bank ID
*/
private final int id;
/**
* The Bank Name
*/
private final AtomicReference<String> name = new AtomicReference<>(null);
/**
* Create the object from the given ID (using the default name of "Bank" + id)
*
* @param id a bank identifier between 1 and 6
* @throws IllegalArgumentException if id is < 1 or > 6
*/
public RioBank(int id) {
this(id, null);
}
/**
* Create the object from the given ID and given name. If the name is empty or null, the name will default to ("Bank
* " + id)
*
* @param id a bank identifier between 1 and 6
* @param name a possibly null, possibly empty bank name (null or empty will result in a bank name of "Bank "+ id)
* @throws IllegalArgumentException if id is < 1 or > 6
*/
public RioBank(int id, String name) {
if (id < 1 || id > 6) {
throw new IllegalArgumentException("Bank ID can only be between 1 and 6");
}
this.id = id;
this.name.set(StringUtils.isEmpty(name) ? "Bank " + id : name);
}
/**
* Returns the bank identifier
*
* @return the bank identifier between 1 and 6
*/
public int getId() {
return id;
}
/**
* Returns the bank name
*
* @return a non-null, non-empty bank name
*/
public String getName() {
return name.get();
}
/**
* Sets the bank name. If empty or a null, name defaults to "Bank " + getId()
*
* @param bankName a possibly null, possibly empty bank name
*/
public void setName(String bankName) {
name.set(StringUtils.isEmpty(bankName) ? "Bank " + getId() : bankName);
}
}

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.models;
import java.lang.reflect.Type;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* A {@link JsonSerializer} and {@link JsonDeserializer} for the {@link RioBank}. Simply writes/reads the ID and name to
* elements called "id" and "name"
*
* @author Tim Roberts - Initial contribution
*/
public class RioBankSerializer implements JsonSerializer<RioBank>, JsonDeserializer<RioBank> {
/**
* Overridden to simply write out the id/name elements from the {@link RioBank}
*
* @param bank the {@link RioBank} to write out
* @param type the type
* @param context the serialization context
*/
@Override
public JsonElement serialize(RioBank bank, Type type, JsonSerializationContext context) {
JsonObject root = new JsonObject();
root.addProperty("id", bank.getId());
root.addProperty("name", bank.getName());
return root;
}
/**
* Overridden to simply read the id/name elements and create a {@link RioBank}
*
* @param elm the {@link JsonElement} to read from
* @param type the type
* @param context the serialization context
*/
@Override
public RioBank deserialize(JsonElement elm, Type type, JsonDeserializationContext context)
throws JsonParseException {
final JsonObject jo = (JsonObject) elm;
final JsonElement id = jo.get("id");
final JsonElement name = jo.get("name");
return new RioBank((id == null ? -1 : id.getAsInt()), (name == null ? null : name.getAsString()));
}
}

View File

@@ -0,0 +1,120 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.models;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.lang.StringUtils;
/**
* Simple model of a RIO Favorite (both system and zone) and it's attributes. Please note this class is used to
* serialize/deserialize to JSON.
*
* @author Tim Roberts - Initial contribution
*/
public class RioFavorite {
/**
* The favorite ID
*/
private final int id;
/**
* Whether the favorite is valid or not
*/
private final AtomicBoolean valid = new AtomicBoolean(false);
/**
* The favorite name
*/
private final AtomicReference<String> name = new AtomicReference<>(null);
/**
* Simply creates the favorite from the given ID. The favorite will not be valid and the name will default to
* "Favorite " + id
*
* @param id a favorite ID between 1 and 32
* @throws IllegalArgumentException if id < 1 or > 32
*/
public RioFavorite(int id) {
this(id, false, null);
}
/**
* Creates the favorite from the given ID, validity and name. If the name is empty or null, it will default to
* "Favorite " + id
*
* @param id a favorite ID between 1 and 32
* @param isValid true if the favorite is valid, false otherwise
* @param name a possibly null, possibly empty favorite name
* @throws IllegalArgumentException if id < 1 or > 32
*/
public RioFavorite(int id, boolean isValid, String name) {
if (id < 1 || id > 32) {
throw new IllegalArgumentException("Favorite ID must be between 1 and 32");
}
if (StringUtils.isEmpty(name)) {
name = "Favorite " + id;
}
this.id = id;
this.valid.set(isValid);
this.name.set(name);
}
/**
* Returns the favorite identifier
*
* @return a favorite id between 1 and 32
*/
public int getId() {
return id;
}
/**
* Returns true if the favorite is valid, false otherwise
*
* @return true if valid, false otherwise
*/
public boolean isValid() {
return valid.get();
}
/**
* Sets whether the favorite is valid or not
*
* @param favValid true if valid, false otherwise
*/
public void setValid(boolean favValid) {
valid.set(favValid);
}
/**
* Set's the favorite name. If null or empty, will default to "Favorite " + getId()
*
* @param favName a possibly null, possibly empty favorite name
*/
public void setName(String favName) {
name.set(StringUtils.isEmpty(favName) ? "Favorite " + getId() : favName);
}
/**
* Returns the favorite name
*
* @return a non-null, non-empty favorite name
*/
public String getName() {
return name.get();
}
}

View File

@@ -0,0 +1,69 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.models;
import java.lang.reflect.Type;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* A {@link JsonSerializer} and {@link JsonDeserializer} for the {@link RioFavorite}. Simply writes/reads the ID and
* name to elements called "id", "valid" and "name"
*
* @author Tim Roberts - Initial contribution
*/
public class RioFavoriteSerializer implements JsonSerializer<RioFavorite>, JsonDeserializer<RioFavorite> {
/**
* Overridden to simply write out the id/valid/name elements from the {@link RioFavorite}
*
* @param favorite the {@link RioFavorite} to write out
* @param type the type
* @param context the serialization context
*/
@Override
public JsonElement serialize(RioFavorite favorite, Type type, JsonSerializationContext context) {
JsonObject root = new JsonObject();
root.addProperty("id", favorite.getId());
root.addProperty("valid", favorite.isValid());
root.addProperty("name", favorite.getName());
return root;
}
/**
* Overridden to simply read the id/valid/name elements and create a {@link RioFavorite}
*
* @param elm the {@link JsonElement} to read from
* @param type the type
* @param context the serialization context
*/
@Override
public RioFavorite deserialize(JsonElement elm, Type type, JsonDeserializationContext context)
throws JsonParseException {
final JsonObject jo = (JsonObject) elm;
final JsonElement id = jo.get("id");
final JsonElement valid = jo.get("valid");
final JsonElement name = jo.get("name");
return new RioFavorite((id == null ? -1 : id.getAsInt()), (valid == null ? false : valid.getAsBoolean()),
(name == null ? null : name.getAsString()));
}
}

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.models;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.lang.StringUtils;
/**
* Simple model of a RIO Preset and it's attributes. Please note this class is used to
* serialize/deserialize to JSON.
*
* @author Tim Roberts - Initial contribution
*/
public class RioPreset {
/**
* The preset id
*/
private final int id;
/**
* Whether the preset is valid or not
*/
private final AtomicBoolean valid = new AtomicBoolean(false);
/**
* The preset name
*/
private final AtomicReference<String> name = new AtomicReference<>(null);
/**
* Simply creates the preset from the given ID. The preset will not be valid and the name will default to
* "Preset " + id
*
* @param id a preset ID between 1 and 36
* @throws IllegalArgumentException if id < 1 or > 36
*/
public RioPreset(int id) {
this(id, false, "Preset " + id);
}
/**
* Creates the preset from the given ID, validity and name. If the name is empty or null, it will default to
* "Preset " + id
*
* @param id a preset ID between 1 and 36
* @param isValid true if the preset is valid, false otherwise
* @param name a possibly null, possibly empty preset name
* @throws IllegalArgumentException if id < 1 or > 32
*/
public RioPreset(int id, boolean valid, String name) {
if (id < 1 || id > 36) {
throw new IllegalArgumentException("Preset ID can only be between 1 and 36");
}
if (StringUtils.isEmpty(name)) {
name = "Preset " + id;
}
this.id = id;
this.valid.set(valid);
this.name.set(name);
}
/**
* Returns the bank identifier this preset is for
*
* @return bank identifier between 1 and 6
*/
public int getBank() {
return ((getId() - 1) / 6) + 1;
}
/**
* Returns the bank preset identifier this preset is for
*
* @return bank preset identifier between 1 and 6
*/
public int getBankPreset() {
return ((getId() - 1) % 6) + 1;
}
/**
* Returns the preset identifier
*
* @return the preset identifier between 1 and 36
*/
public int getId() {
return id;
}
/**
* Returns true if the preset is valid, false otherwise
*
* @return true if valid, false otherwise
*/
public boolean isValid() {
return valid.get();
}
/**
* Sets whether the preset is valid (true) or not (false)
*
* @param presetValid true if valid, false otherwise
*/
public void setValid(boolean presetValid) {
valid.set(presetValid);
}
/**
* Set's the preset name. If null or empty, will default to "Preset " + getId()
*
* @param presetName a possibly null, possibly empty preset name
*/
public void setName(String presetName) {
name.set(StringUtils.isEmpty(presetName) ? "Preset " + getId() : presetName);
}
/**
* Returns the preset name
*
* @return a non-null, non-empty preset name
*/
public String getName() {
return name.get();
}
}

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.models;
import java.lang.reflect.Type;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* A {@link JsonSerializer} and {@link JsonDeserializer} for the {@link RioPreset}. Simply writes/reads the ID and
* name to elements called "id", "valid", "name", "bank" and "bankPreset" values.
*
* @author Tim Roberts - Initial contribution
*/
public class RioPresetSerializer implements JsonSerializer<RioPreset>, JsonDeserializer<RioPreset> {
/**
* Overridden to simply write out the id/valid/name/bank/bankPreset elements from the {@link RioPreset}
*
* @param preset the {@link RioPreset} to write out
* @param type the type
* @param context the serialization context
*/
@Override
public JsonElement serialize(RioPreset preset, Type type, JsonSerializationContext context) {
JsonObject root = new JsonObject();
root.addProperty("id", preset.getId());
root.addProperty("valid", preset.isValid());
root.addProperty("name", preset.getName());
root.addProperty("bank", preset.getBank());
root.addProperty("bankPreset", preset.getBankPreset());
return root;
}
/**
* Overridden to simply read the id/valid/name elements and create a {@link RioPreset}. Please note that
* the bank/bankPreset are calculated fields from the ID and do not need to be read.
*
* @param elm the {@link JsonElement} to read from
* @param type the type
* @param context the serialization context
*/
@Override
public RioPreset deserialize(JsonElement elm, Type type, JsonDeserializationContext context)
throws JsonParseException {
final JsonObject jo = (JsonObject) elm;
final JsonElement id = jo.get("id");
final JsonElement valid = jo.get("valid");
final JsonElement name = jo.get("name");
return new RioPreset((id == null ? -1 : id.getAsInt()), (valid == null ? false : valid.getAsBoolean()),
(name == null ? null : name.getAsString()));
}
}

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.source;
/**
* Configuration class for the {@link RioSourceHandler}
*
* @author Tim Roberts - Initial contribution
*/
public class RioSourceConfig {
/**
* Constant defined for the "source" configuration field
*/
public static final String SOURCE = "source";
/**
* ID of the source
*/
private int source;
/**
* Gets the source identifier
*
* @return the source identifier
*/
public int getSource() {
return source;
}
/**
* Sets the source identifier
*
* @param source the source identifier
*/
public void setSource(int source) {
this.source = source;
}
}

View File

@@ -0,0 +1,285 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.source;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.rio.AbstractRioHandlerCallback;
import org.openhab.binding.russound.internal.rio.AbstractThingHandler;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioNamedHandler;
import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback;
import org.openhab.binding.russound.internal.rio.system.RioSystemHandler;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The bridge handler for a Russound Source. A source provides source music to the russound system (along with metadata
* about the streaming music). This implementation must be attached to a {@link RioSystemHandler} bridge.
*
* @author Tim Roberts - Initial contribution
*/
public class RioSourceHandler extends AbstractThingHandler<RioSourceProtocol> implements RioNamedHandler {
// Logger
private final Logger logger = LoggerFactory.getLogger(RioSourceHandler.class);
/**
* The source identifier for this instance (1-12)
*/
private final AtomicInteger source = new AtomicInteger(0);
/**
* The source name
*/
private final AtomicReference<String> sourceName = new AtomicReference<>(null);
/**
* Constructs the handler from the {@link Thing}
*
* @param thing a non-null {@link Thing} the handler is for
*/
public RioSourceHandler(Thing thing) {
super(thing);
}
/**
* Returns the source identifier for this instance
*
* @return the source identifier
*/
@Override
public int getId() {
return source.get();
}
/**
* Returns the source name for this instance
*
* @return the source name
*/
@Override
public String getName() {
final String name = sourceName.get();
return StringUtils.isEmpty(name) ? ("Source " + getId()) : name;
}
/**
* {@inheritDoc}
*
* Handles commands to specific channels. This implementation will offload much of its work to the
* {@link RioSourceProtocol}. Basically we validate the type of command for the channel then call the
* {@link RioSourceProtocol} to handle the actual protocol. Special use case is the {@link RefreshType}
* where we call {{@link #handleRefresh(String)} to handle a refresh of the specific channel (which in turn calls
* {@link RioSourceProtocol} to handle the actual refresh
*/
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
handleRefresh(channelUID.getId());
return;
}
// if (getThing().getStatus() != ThingStatus.ONLINE) {
// // Ignore any command if not online
// return;
// }
String id = channelUID.getId();
if (id == null) {
logger.debug("Called with a null channel id - ignoring");
return;
}
if (id.equals(RioConstants.CHANNEL_SOURCEBANKS)) {
if (command instanceof StringType) {
// Remove any state for this channel to ensure it's recreated/sent again
// (clears any bad or deleted favorites information from the channel)
((StatefulHandlerCallback) getProtocolHandler().getCallback())
.removeState(RioConstants.CHANNEL_SOURCEBANKS);
// schedule the returned callback in the future (to allow the channel to process and to allow russound
// to process (before re-retrieving information)
scheduler.schedule(getProtocolHandler().setBanks(command.toString()), 250, TimeUnit.MILLISECONDS);
} else {
logger.debug("Received a BANKS channel command with a non StringType: {}", command);
}
} else {
logger.debug("Unknown/Unsupported Channel id: {}", id);
}
}
/**
* Method that handles the {@link RefreshType} command specifically. Calls the {@link RioSourceProtocol} to
* handle the actual refresh based on the channel id.
*
* @param id a non-null, possibly empty channel id to refresh
*/
private void handleRefresh(String id) {
if (getThing().getStatus() != ThingStatus.ONLINE) {
return;
}
if (getProtocolHandler() == null) {
return;
}
// Remove the cache'd value to force a refreshed value
((StatefulHandlerCallback) getProtocolHandler().getCallback()).removeState(id);
if (id.equals(RioConstants.CHANNEL_SOURCENAME)) {
getProtocolHandler().refreshSourceName();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCETYPE)) {
getProtocolHandler().refreshSourceType();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCECOMPOSERNAME)) {
getProtocolHandler().refreshSourceComposerName();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCECHANNEL)) {
getProtocolHandler().refreshSourceChannel();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCECHANNELNAME)) {
getProtocolHandler().refreshSourceChannelName();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCEGENRE)) {
getProtocolHandler().refreshSourceGenre();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCEARTISTNAME)) {
getProtocolHandler().refreshSourceArtistName();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCEALBUMNAME)) {
getProtocolHandler().refreshSourceAlbumName();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCECOVERARTURL)) {
getProtocolHandler().refreshSourceCoverArtUrl();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCEPLAYLISTNAME)) {
getProtocolHandler().refreshSourcePlaylistName();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCESONGNAME)) {
getProtocolHandler().refreshSourceSongName();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCEMODE)) {
getProtocolHandler().refreshSourceMode();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCESHUFFLEMODE)) {
getProtocolHandler().refreshSourceShuffleMode();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCEREPEATMODE)) {
getProtocolHandler().refreshSourceRepeatMode();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCERATING)) {
getProtocolHandler().refreshSourceRating();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCEPROGRAMSERVICENAME)) {
getProtocolHandler().refreshSourceProgramServiceName();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT)) {
getProtocolHandler().refreshSourceRadioText();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT2)) {
getProtocolHandler().refreshSourceRadioText2();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT3)) {
getProtocolHandler().refreshSourceRadioText3();
} else if (id.startsWith(RioConstants.CHANNEL_SOURCERADIOTEXT4)) {
getProtocolHandler().refreshSourceRadioText4();
} else if (id.equals(RioConstants.CHANNEL_SOURCEBANKS)) {
getProtocolHandler().refreshBanks();
}
// Can't refresh any others...
}
/**
* Initializes the bridge. Confirms the configuration is valid and that our parent bridge is a
* {@link RioSystemHandler}. Once validated, a {@link RioSystemProtocol} is set via
* {@link #setProtocolHandler(RioSystemProtocol)} and the bridge comes online.
*/
@Override
public void initialize() {
logger.debug("Initializing");
final Bridge bridge = getBridge();
if (bridge == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Cannot be initialized without a bridge");
return;
}
if (bridge.getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
return;
}
final ThingHandler handler = bridge.getHandler();
if (handler == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No handler specified (null) for the bridge!");
return;
}
if (!(handler instanceof RioSystemHandler)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Source must be attached to a System bridge: " + handler.getClass());
return;
}
final RioSourceConfig config = getThing().getConfiguration().as(RioSourceConfig.class);
if (config == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing");
return;
}
final int configSource = config.getSource();
if (configSource < 1 || configSource > 12) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Source must be between 1 and 12: " + configSource);
return;
}
source.set(configSource);
// Get the socket session from the
final SocketSession socketSession = getSocketSession();
if (socketSession == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "No socket session found");
return;
}
try {
setProtocolHandler(new RioSourceProtocol(configSource, socketSession,
new StatefulHandlerCallback(new AbstractRioHandlerCallback() {
@Override
public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) {
updateStatus(status, detail, msg);
}
@Override
public void stateChanged(String channelId, State state) {
if (channelId.equals(RioConstants.CHANNEL_SOURCENAME)) {
sourceName.set(state.toString());
}
if (state != null) {
updateState(channelId, state);
fireStateUpdated(channelId, state);
}
}
@Override
public void setProperty(String propertyName, String propertyValue) {
getThing().setProperty(propertyName, propertyValue);
}
})));
updateStatus(ThingStatus.ONLINE);
getProtocolHandler().postOnline();
} catch (Exception e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.toString());
}
}
}

View File

@@ -0,0 +1,765 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.source;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.NullArgumentException;
import org.apache.commons.lang.StringUtils;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.binding.russound.internal.rio.AbstractRioProtocol;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback;
import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
import org.openhab.binding.russound.internal.rio.models.RioBank;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
/**
* This is the protocol handler for the Russound Source. This handler will issue the protocol commands and will
* process the responses from the Russound system. Please see documentation for what channels are supported by which
* source types.
*
* @author Tim Roberts - Initial contribution
*/
class RioSourceProtocol extends AbstractRioProtocol {
private final Logger logger = LoggerFactory.getLogger(RioSourceProtocol.class);
/**
* The source identifier (1-12)
*/
private final int source;
// Protocol constants
private static final String SRC_NAME = "name";
private static final String SRC_TYPE = "type";
private static final String SRC_IPADDRESS = "ipaddress";
private static final String SRC_COMPOSERNAME = "composername";
private static final String SRC_CHANNEL = "channel";
private static final String SRC_CHANNELNAME = "channelname";
private static final String SRC_GENRE = "genre";
private static final String SRC_ARTISTNAME = "artistname";
private static final String SRC_ALBUMNAME = "albumname";
private static final String SRC_COVERARTURL = "coverarturl";
private static final String SRC_PLAYLISTNAME = "playlistname";
private static final String SRC_SONGNAME = "songname";
private static final String SRC_MODE = "mode";
private static final String SRC_SHUFFLEMODE = "shufflemode";
private static final String SRC_REPEATMODE = "repeatmode";
private static final String SRC_RATING = "rating";
private static final String SRC_PROGRAMSERVICENAME = "programservicename";
private static final String SRC_RADIOTEXT = "radiotext";
private static final String SRC_RADIOTEXT2 = "radiotext2";
private static final String SRC_RADIOTEXT3 = "radiotext3";
private static final String SRC_RADIOTEXT4 = "radiotext4";
// Multimedia channels
private static final String SRC_MMSCREEN = "mmscreen";
private static final String SRC_MMTITLE = "mmtitle.text";
private static final String SRC_MMATTR = "attr";
private static final String SRC_MMBTNOK = "mmbtnok.text";
private static final String SRC_MMBTNBACK = "mmbtnback.text";
private static final String SRC_MMINFOBLOCK = "mminfoblock.text";
private static final String SRC_MMHELP = "mmhelp.text";
private static final String SRC_MMTEXTFIELD = "mmtextfield.text";
// This is an undocumented volume
private static final String SRC_VOLUME = "volume";
private static final String BANK_NAME = "name";
// Response patterns
private static final Pattern RSP_MMMENUNOTIFICATION = Pattern.compile("^\\{.*\\}$");
private static final Pattern RSP_SRCNOTIFICATION = Pattern
.compile("(?i)^[SN] S\\[(\\d+)\\]\\.([a-zA-Z_0-9.\\[\\]]+)=\"(.*)\"$");
private static final Pattern RSP_BANKNOTIFICATION = Pattern
.compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
private static final Pattern RSP_PRESETNOTIFICATION = Pattern
.compile("(?i)^[SN] S\\[(\\d+)\\].B\\[(\\d+)\\].P\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
/**
* Current banks
*/
private final RioBank[] banks = new RioBank[6];
/**
* {@link Gson} use to create/read json
*/
private final Gson gson;
/**
* Lock used to control access to {@link #infoText}
*/
private final Lock infoLock = new ReentrantLock();
/**
* The information text appeneded from media management calls
*/
private final StringBuilder infoText = new StringBuilder(100);
/**
* The table of channels to unique identifiers for media management functions
*/
@SuppressWarnings("serial")
private final Map<String, AtomicInteger> mmSeqNbrs = Collections
.unmodifiableMap(new HashMap<String, AtomicInteger>() {
{
put(RioConstants.CHANNEL_SOURCEMMMENU, new AtomicInteger(0));
put(RioConstants.CHANNEL_SOURCEMMSCREEN, new AtomicInteger(0));
put(RioConstants.CHANNEL_SOURCEMMTITLE, new AtomicInteger(0));
put(RioConstants.CHANNEL_SOURCEMMATTR, new AtomicInteger(0));
put(RioConstants.CHANNEL_SOURCEMMBUTTONOKTEXT, new AtomicInteger(0));
put(RioConstants.CHANNEL_SOURCEMMBUTTONBACKTEXT, new AtomicInteger(0));
put(RioConstants.CHANNEL_SOURCEMMINFOTEXT, new AtomicInteger(0));
put(RioConstants.CHANNEL_SOURCEMMHELPTEXT, new AtomicInteger(0));
put(RioConstants.CHANNEL_SOURCEMMTEXTFIELD, new AtomicInteger(0));
}
});
/**
* The client used for http requests
*/
private final HttpClient httpClient;
/**
* Constructs the protocol handler from given parameters
*
* @param source the source identifier
* @param session a non-null {@link SocketSession} (may be connected or disconnected)
* @param callback a non-null {@link RioHandlerCallback} to callback
* @throws Exception exception when starting the {@link HttpClient}
*/
RioSourceProtocol(int source, SocketSession session, RioHandlerCallback callback) throws Exception {
super(session, callback);
if (source < 1 || source > 12) {
throw new IllegalArgumentException("Source must be between 1-12: " + source);
}
this.source = source;
httpClient = new HttpClient();
httpClient.setFollowRedirects(true);
httpClient.start();
gson = GsonUtilities.createGson();
for (int x = 1; x <= 6; x++) {
banks[x - 1] = new RioBank(x);
}
}
/**
* Helper method to issue post online commands
*/
void postOnline() {
watchSource(true);
refreshSourceIpAddress();
refreshSourceName();
updateBanksChannel();
}
/**
* Helper method to refresh a source key
*
* @param keyName a non-null, non-empty source key to refresh
* @throws IllegalArgumentException if keyName is null or empty
*/
private void refreshSourceKey(String keyName) {
if (keyName == null || keyName.trim().length() == 0) {
throw new IllegalArgumentException("keyName cannot be null or empty");
}
sendCommand("GET S[" + source + "]." + keyName);
}
/**
* Refreshes the source name
*/
void refreshSourceName() {
refreshSourceKey(SRC_NAME);
}
/**
* Refresh the source model type
*/
void refreshSourceType() {
refreshSourceKey(SRC_TYPE);
}
/**
* Refresh the source ip address
*/
void refreshSourceIpAddress() {
refreshSourceKey(SRC_IPADDRESS);
}
/**
* Refresh composer name
*/
void refreshSourceComposerName() {
refreshSourceKey(SRC_COMPOSERNAME);
}
/**
* Refresh the channel frequency (for tuners)
*/
void refreshSourceChannel() {
refreshSourceKey(SRC_CHANNEL);
}
/**
* Refresh the channel's name
*/
void refreshSourceChannelName() {
refreshSourceKey(SRC_CHANNELNAME);
}
/**
* Refresh the song's genre
*/
void refreshSourceGenre() {
refreshSourceKey(SRC_GENRE);
}
/**
* Refresh the artist name
*/
void refreshSourceArtistName() {
refreshSourceKey(SRC_ARTISTNAME);
}
/**
* Refresh the album name
*/
void refreshSourceAlbumName() {
refreshSourceKey(SRC_ALBUMNAME);
}
/**
* Refresh the cover art URL
*/
void refreshSourceCoverArtUrl() {
refreshSourceKey(SRC_COVERARTURL);
}
/**
* Refresh the playlist name
*/
void refreshSourcePlaylistName() {
refreshSourceKey(SRC_PLAYLISTNAME);
}
/**
* Refresh the song name
*/
void refreshSourceSongName() {
refreshSourceKey(SRC_SONGNAME);
}
/**
* Refresh the provider mode/streaming service
*/
void refreshSourceMode() {
refreshSourceKey(SRC_MODE);
}
/**
* Refresh the shuffle mode
*/
void refreshSourceShuffleMode() {
refreshSourceKey(SRC_SHUFFLEMODE);
}
/**
* Refresh the repeat mode
*/
void refreshSourceRepeatMode() {
refreshSourceKey(SRC_REPEATMODE);
}
/**
* Refresh the rating of the song
*/
void refreshSourceRating() {
refreshSourceKey(SRC_RATING);
}
/**
* Refresh the program service name
*/
void refreshSourceProgramServiceName() {
refreshSourceKey(SRC_PROGRAMSERVICENAME);
}
/**
* Refresh the radio text
*/
void refreshSourceRadioText() {
refreshSourceKey(SRC_RADIOTEXT);
}
/**
* Refresh the radio text (line #2)
*/
void refreshSourceRadioText2() {
refreshSourceKey(SRC_RADIOTEXT2);
}
/**
* Refresh the radio text (line #3)
*/
void refreshSourceRadioText3() {
refreshSourceKey(SRC_RADIOTEXT3);
}
/**
* Refresh the radio text (line #4)
*/
void refreshSourceRadioText4() {
refreshSourceKey(SRC_RADIOTEXT4);
}
/**
* Refresh the source volume
*/
void refreshSourceVolume() {
refreshSourceKey(SRC_VOLUME);
}
/**
* Refreshes the names of the banks
*/
void refreshBanks() {
for (int b = 1; b <= 6; b++) {
sendCommand("GET S[" + source + "].B[" + b + "]." + BANK_NAME);
}
}
/**
* Sets the bank names from the supplied bank JSON and returns a runnable to call {@link #updateBanksChannel()}
*
* @param bankJson a possibly null, possibly empty json containing the {@link RioBank} to update
* @return a non-null {@link Runnable} to execute after this call
*/
Runnable setBanks(String bankJson) {
// If null or empty - simply return a do nothing runnable
if (StringUtils.isEmpty(bankJson)) {
return () -> {
};
}
try {
final RioBank[] newBanks;
newBanks = gson.fromJson(bankJson, RioBank[].class);
for (int x = 0; x < newBanks.length; x++) {
final RioBank bank = newBanks[x];
if (bank == null) {
continue; // caused by {id,valid,name},,{id,valid,name}
}
final int bankId = bank.getId();
if (bankId < 1 || bankId > 6) {
logger.debug("Invalid bank id (not between 1 and 6) - ignoring: {}:{}", bankId, bankJson);
} else {
final RioBank myBank = banks[bankId - 1];
if (!StringUtils.equals(myBank.getName(), bank.getName())) {
myBank.setName(bank.getName());
sendCommand(
"SET S[" + source + "].B[" + bankId + "]." + BANK_NAME + "=\"" + bank.getName() + "\"");
}
}
}
} catch (JsonSyntaxException e) {
logger.debug("Invalid JSON: {}", e.getMessage(), e);
}
// regardless of what happens above - reupdate the channel
// (to remove anything bad from it)
return this::updateBanksChannel;
}
/**
* Helper method to simply update the banks channel. Will create a JSON representation from {@link #banks} and send
* it via the channel
*/
private void updateBanksChannel() {
final String bankJson = gson.toJson(banks);
stateChanged(RioConstants.CHANNEL_SOURCEBANKS, new StringType(bankJson));
}
/**
* Turns on/off watching the source for notifications
*
* @param watch true to turn on, false to turn off
*/
void watchSource(boolean watch) {
sendCommand("WATCH S[" + source + "] " + (watch ? "ON" : "OFF"));
}
/**
* Helper method to handle any media management change. If the channel is the INFO text channel, we delegate to
* {@link #handleMMInfoText(String)} instead. This helper method will simply get the next MM identifier and send the
* json representation out for the channel change (this ensures unique messages for each MM notification)
*
* @param channelId a non-null, non-empty channelId
* @param value the value for the channel
* @throws IllegalArgumentException if channelID is null or empty
*/
private void handleMMChange(String channelId, String value) {
if (StringUtils.isEmpty(channelId)) {
throw new NullArgumentException("channelId cannot be null or empty");
}
final AtomicInteger ai = mmSeqNbrs.get(channelId);
if (ai == null) {
logger.error("Channel {} does not have an ID configuration - programmer error!", channelId);
} else {
if (channelId.equals(RioConstants.CHANNEL_SOURCEMMINFOTEXT)) {
value = handleMMInfoText(value);
if (value == null) {
return;
}
}
final int id = ai.getAndIncrement();
final String json = gson.toJson(new IdValue(id, value));
stateChanged(channelId, new StringType(json));
}
}
/**
* Helper method to handle MMInfoText notifications. There may be multiple infotext messages that represent a single
* message. We know when we get the last info text when the MMATTR contains an 'E' (last item). Once we have the
* last item, we update the channel with the complete message.
*
* @param infoTextValue the last info text value
* @return a non-null containing the complete or null if the message isn't complete yet
*/
private String handleMMInfoText(String infoTextValue) {
final StatefulHandlerCallback callback = ((StatefulHandlerCallback) getCallback());
final State attr = callback.getProperty(RioConstants.CHANNEL_SOURCEMMATTR);
infoLock.lock();
try {
infoText.append(infoTextValue.toString());
if (attr != null && attr.toString().indexOf("E") >= 0) {
final String text = infoText.toString();
infoText.setLength(0);
callback.removeState(RioConstants.CHANNEL_SOURCEMMATTR);
return text;
}
return null;
} finally {
infoLock.unlock();
}
}
/**
* Handles any source notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
private void handleSourceNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 3) {
try {
final int notifySource = Integer.parseInt(m.group(1));
if (notifySource != source) {
return;
}
final String key = m.group(2).toLowerCase();
final String value = m.group(3);
switch (key) {
case SRC_NAME:
stateChanged(RioConstants.CHANNEL_SOURCENAME, new StringType(value));
break;
case SRC_TYPE:
stateChanged(RioConstants.CHANNEL_SOURCETYPE, new StringType(value));
break;
case SRC_IPADDRESS:
setProperty(RioConstants.PROPERTY_SOURCEIPADDRESS, value);
break;
case SRC_COMPOSERNAME:
stateChanged(RioConstants.CHANNEL_SOURCECOMPOSERNAME, new StringType(value));
break;
case SRC_CHANNEL:
stateChanged(RioConstants.CHANNEL_SOURCECHANNEL, new StringType(value));
break;
case SRC_CHANNELNAME:
stateChanged(RioConstants.CHANNEL_SOURCECHANNELNAME, new StringType(value));
break;
case SRC_GENRE:
stateChanged(RioConstants.CHANNEL_SOURCEGENRE, new StringType(value));
break;
case SRC_ARTISTNAME:
stateChanged(RioConstants.CHANNEL_SOURCEARTISTNAME, new StringType(value));
break;
case SRC_ALBUMNAME:
stateChanged(RioConstants.CHANNEL_SOURCEALBUMNAME, new StringType(value));
break;
case SRC_COVERARTURL:
stateChanged(RioConstants.CHANNEL_SOURCECOVERARTURL, new StringType(value));
break;
case SRC_PLAYLISTNAME:
stateChanged(RioConstants.CHANNEL_SOURCEPLAYLISTNAME, new StringType(value));
break;
case SRC_SONGNAME:
stateChanged(RioConstants.CHANNEL_SOURCESONGNAME, new StringType(value));
break;
case SRC_MODE:
stateChanged(RioConstants.CHANNEL_SOURCEMODE, new StringType(value));
break;
case SRC_SHUFFLEMODE:
stateChanged(RioConstants.CHANNEL_SOURCESHUFFLEMODE, new StringType(value));
break;
case SRC_REPEATMODE:
stateChanged(RioConstants.CHANNEL_SOURCEREPEATMODE, new StringType(value));
break;
case SRC_RATING:
stateChanged(RioConstants.CHANNEL_SOURCERATING, new StringType(value));
break;
case SRC_PROGRAMSERVICENAME:
stateChanged(RioConstants.CHANNEL_SOURCEPROGRAMSERVICENAME, new StringType(value));
break;
case SRC_RADIOTEXT:
stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT, new StringType(value));
break;
case SRC_RADIOTEXT2:
stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT2, new StringType(value));
break;
case SRC_RADIOTEXT3:
stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT3, new StringType(value));
break;
case SRC_RADIOTEXT4:
stateChanged(RioConstants.CHANNEL_SOURCERADIOTEXT4, new StringType(value));
break;
case SRC_VOLUME:
stateChanged(RioConstants.CHANNEL_SOURCEVOLUME, new StringType(value));
break;
case SRC_MMSCREEN:
handleMMChange(RioConstants.CHANNEL_SOURCEMMSCREEN, value);
break;
case SRC_MMTITLE:
handleMMChange(RioConstants.CHANNEL_SOURCEMMTITLE, value);
break;
case SRC_MMATTR:
handleMMChange(RioConstants.CHANNEL_SOURCEMMATTR, value);
break;
case SRC_MMBTNOK:
handleMMChange(RioConstants.CHANNEL_SOURCEMMBUTTONOKTEXT, value);
break;
case SRC_MMBTNBACK:
handleMMChange(RioConstants.CHANNEL_SOURCEMMBUTTONBACKTEXT, value);
break;
case SRC_MMHELP:
handleMMChange(RioConstants.CHANNEL_SOURCEMMHELPTEXT, value);
break;
case SRC_MMTEXTFIELD:
handleMMChange(RioConstants.CHANNEL_SOURCEMMTEXTFIELD, value);
break;
case SRC_MMINFOBLOCK:
handleMMChange(RioConstants.CHANNEL_SOURCEMMINFOTEXT, value);
break;
default:
logger.warn("Unknown source notification: '{}'", resp);
break;
}
} catch (NumberFormatException e) {
logger.warn("Invalid Source Notification (source not a parsable integer): '{}')", resp);
}
} else {
logger.warn("Invalid Source Notification response: '{}'", resp);
}
}
/**
* Handles any bank notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
private void handleBankNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
// System notification
if (m.groupCount() == 4) {
try {
final int bank = Integer.parseInt(m.group(2));
if (bank >= 1 && bank <= 6) {
final int notifySource = Integer.parseInt(m.group(1));
if (notifySource != source) {
return;
}
final String key = m.group(3).toLowerCase();
final String value = m.group(4);
switch (key) {
case BANK_NAME:
banks[bank - 1].setName(value);
updateBanksChannel();
break;
default:
logger.warn("Unknown bank name notification: '{}'", resp);
break;
}
} else {
logger.debug("Bank ID must be between 1 and 6: {}", resp);
}
} catch (NumberFormatException e) {
logger.warn("Invalid Bank Name Notification (bank/source not a parsable integer): '{}')", resp);
}
} else {
logger.warn("Invalid Bank Notification: '{}')", resp);
}
}
/**
* Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
* russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
*
* @param a possibly null, possibly empty response
*/
@Override
public void responseReceived(String response) {
if (StringUtils.isEmpty(response)) {
return;
}
Matcher m = RSP_BANKNOTIFICATION.matcher(response);
if (m.matches()) {
handleBankNotification(m, response);
return;
}
m = RSP_PRESETNOTIFICATION.matcher(response);
if (m.matches()) {
// does nothing
return;
}
m = RSP_SRCNOTIFICATION.matcher(response);
if (m.matches()) {
handleSourceNotification(m, response);
}
m = RSP_MMMENUNOTIFICATION.matcher(response);
if (m.matches()) {
try {
handleMMChange(RioConstants.CHANNEL_SOURCEMMMENU, response);
} catch (NumberFormatException e) {
logger.debug("Could not parse the menu text (1) from {}", response);
}
}
}
/**
* Overrides the default implementation to turn watch off ({@link #watchSource(boolean)}) before calling the dispose
*/
@Override
public void dispose() {
watchSource(false);
if (httpClient != null) {
try {
httpClient.stop();
} catch (Exception e) {
logger.debug("Error stopping the httpclient", e);
}
}
super.dispose();
}
/**
* The following class is simply used as a model for an id/value combination that will be serialized to JSON.
* Nothing needs to be public because the serialization walks the properties.
*
* @author Tim Roberts
*
*/
@SuppressWarnings("unused")
private class IdValue {
/** The id of the value */
private final int id;
/** The value for the id */
private final String value;
/**
* Constructions ID/Value from the given parms (no validations are done)
*
* @param id the identifier
* @param value the associated value
*/
public IdValue(int id, String value) {
this.id = id;
this.value = value;
}
}
}

View File

@@ -0,0 +1,175 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.system;
/**
* Configuration class for the {@link RioSystemHandler}
*
* @author Tim Roberts - Initial contribution
*/
public class RioSystemConfig {
/**
* Constant defined for the "ipAddress" configuration field
*/
public static final String IP_ADDRESS = "ipAddress";
/**
* Constant defined for the "ping" configuration field
*/
public static final String PING = "ping";
/**
* Constant defined for the "retryPolling" configuration field
*/
public static final String RETRY_POLLING = "retryPolling";
/**
* Constant defined for the "scanDevice" configuration field
*/
public static final String SCAN_DEVICE = "scanDevice";
/**
* IP Address (or host name) of system
*/
private String ipAddress;
/**
* Ping time (in seconds) to keep the connection alive.
*/
private int ping;
/**
* Polling time (in seconds) to attempt a reconnect if the socket session has failed
*/
private int retryPolling;
/**
* Whether to scan the device at startup (and create zones, source, etc dynamically)
*/
private boolean scanDevice;
/**
* Returns the IP address or host name
*
* @return the IP address or host name
*/
public String getIpAddress() {
return ipAddress;
}
/**
* Sets the IP address or host name
*
* @param ipAddress the IP Address or host name
*/
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
/**
* Gets the polling (in seconds) to reconnect
*
* @return the polling (in seconds) to reconnect
*/
public int getRetryPolling() {
return retryPolling;
}
/**
* Sets the polling (in seconds) to reconnect
*
* @param retryPolling the polling (in seconds to reconnect)
*/
public void setRetryPolling(int retryPolling) {
this.retryPolling = retryPolling;
}
/**
* Gets the ping interval (in seconds)
*
* @return the ping interval (in seconds)
*/
public int getPing() {
return ping;
}
/**
* Sets the ping interval (in seconds)
*
* @param ping the ping interval (in seconds)
*/
public void setPing(int ping) {
this.ping = ping;
}
/**
* Whether the device should be scanned at startup
*
* @return true to scan, false otherwise
*/
public boolean isScanDevice() {
return scanDevice;
}
/**
* Sets whether the device should be scanned at startup
*
* @param scanDevice true to scan, false otherwise
*/
public void setScanDevice(boolean scanDevice) {
this.scanDevice = scanDevice;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((ipAddress == null) ? 0 : ipAddress.hashCode());
result = prime * result + ping;
result = prime * result + retryPolling;
result = prime * result + (scanDevice ? 1231 : 1237);
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final RioSystemConfig other = (RioSystemConfig) obj;
if (ipAddress == null) {
if (other.ipAddress != null) {
return false;
}
} else if (!ipAddress.equals(other.ipAddress)) {
return false;
}
if (ping != other.ping) {
return false;
}
if (retryPolling != other.retryPolling) {
return false;
}
if (scanDevice != other.scanDevice) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,526 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.system;
import java.io.IOException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import org.openhab.binding.russound.internal.discovery.RioSystemDeviceDiscoveryService;
import org.openhab.binding.russound.internal.net.SocketChannelSession;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.rio.AbstractBridgeHandler;
import org.openhab.binding.russound.internal.rio.AbstractRioHandlerCallback;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
import org.openhab.binding.russound.internal.rio.RioHandlerCallbackListener;
import org.openhab.binding.russound.internal.rio.RioPresetsProtocol;
import org.openhab.binding.russound.internal.rio.RioSystemFavoritesProtocol;
import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback;
import org.openhab.binding.russound.internal.rio.controller.RioControllerHandler;
import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
import org.openhab.binding.russound.internal.rio.source.RioSourceHandler;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The bridge handler for a Russound System. This is the entry point into the whole russound system and is generally
* points to the main controller. This implementation must be attached to a {@link RioSystemHandler} bridge.
*
* @author Tim Roberts - Initial contribution
*/
public class RioSystemHandler extends AbstractBridgeHandler<RioSystemProtocol> {
// Logger
private final Logger logger = LoggerFactory.getLogger(RioSystemHandler.class);
/**
* The configuration for the system - will be recreated when the configuration changes and will be null when not
* online
*/
private RioSystemConfig config;
/**
* The lock used to control access to {@link #config}
*/
private final ReentrantLock configLock = new ReentrantLock();
/**
* The {@link SocketSession} telnet session to the switch. Will be null if not connected.
*/
private SocketSession session;
/**
* The lock used to control access to {@link #session}
*/
private final ReentrantLock sessionLock = new ReentrantLock();
/**
* The retry connection event - will only be created when we are retrying the connection attempt
*/
private ScheduledFuture<?> retryConnection;
/**
* The lock used to control access to {@link #retryConnection}
*/
private final ReentrantLock retryConnectionLock = new ReentrantLock();
/**
* The ping event - will be non-null when online (null otherwise)
*/
private ScheduledFuture<?> ping;
/**
* The lock used to control access to {@link #ping}
*/
private final ReentrantLock pingLock = new ReentrantLock();
/**
* {@link Gson} used for JSON serialization/deserialization
*/
private final Gson gson = GsonUtilities.createGson();
/**
* Callback listener to use when source name changes - will call {@link #refreshNamedHandler(Gson, Class, String)}
* to
* refresh the {@link RioConstants#CHANNEL_SYSSOURCES} channel
*/
private final RioHandlerCallbackListener handlerCallbackListener = new RioHandlerCallbackListener() {
@Override
public void stateUpdate(String channelId, State state) {
refreshNamedHandler(gson, RioSourceHandler.class, RioConstants.CHANNEL_SYSSOURCES);
}
};
/**
* The protocol for favorites handling
*/
private final AtomicReference<RioSystemFavoritesProtocol> favoritesProtocol = new AtomicReference<>(null);
/**
* The protocol for presets handling
*/
private final AtomicReference<RioPresetsProtocol> presetsProtocol = new AtomicReference<>(null);
/**
* The discovery service to discover the zones/sources, etc
* Will be null if not active.
*/
private final AtomicReference<RioSystemDeviceDiscoveryService> discoveryService = new AtomicReference<>(null);
/**
* Constructs the handler from the {@link Bridge}
*
* @param bridge a non-null {@link Bridge} the handler is for
*/
public RioSystemHandler(Bridge bridge) {
super(bridge);
}
/**
* Overrides the base method since we are the source of the {@link SocketSession}.
*
* @return the {@link SocketSession} once initialized. Null if not initialized or disposed of
*/
@Override
public SocketSession getSocketSession() {
sessionLock.lock();
try {
return session;
} finally {
sessionLock.unlock();
}
}
/**
* {@inheritDoc}
*
* Handles commands to specific channels. This implementation will offload much of its work to the
* {@link RioSystemProtocol}. Basically we validate the type of command for the channel then call the
* {@link RioSystemProtocol} to handle the actual protocol. Special use case is the {@link RefreshType}
* where we call {{@link #handleRefresh(String)} to handle a refresh of the specific channel (which in turn calls
* {@link RioSystemProtocol} to handle the actual refresh
*/
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
handleRefresh(channelUID.getId());
return;
}
String id = channelUID.getId();
if (id == null) {
logger.debug("Called with a null channel id - ignoring");
return;
}
if (id.equals(RioConstants.CHANNEL_SYSLANG)) {
if (command instanceof StringType) {
getProtocolHandler().setSystemLanguage(((StringType) command).toString());
} else {
logger.debug("Received a SYSTEM LANGUAGE channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_SYSALLON)) {
if (command instanceof OnOffType) {
getProtocolHandler().setSystemAllOn(command == OnOffType.ON);
} else {
logger.debug("Received a SYSTEM ALL ON channel command with a non OnOffType: {}", command);
}
} else {
logger.debug("Unknown/Unsupported Channel id: {}", id);
}
}
/**
* Method that handles the {@link RefreshType} command specifically. Calls the {@link RioSystemProtocol} to
* handle the actual refresh based on the channel id.
*
* @param id a non-null, possibly empty channel id to refresh
*/
private void handleRefresh(String id) {
if (getThing().getStatus() != ThingStatus.ONLINE) {
return;
}
if (getProtocolHandler() == null) {
return;
}
// Remove the cache'd value to force a refreshed value
((StatefulHandlerCallback) getProtocolHandler().getCallback()).removeState(id);
if (id.equals(RioConstants.CHANNEL_SYSLANG)) {
getProtocolHandler().refreshSystemLanguage();
} else if (id.equals(RioConstants.CHANNEL_SYSSTATUS)) {
getProtocolHandler().refreshSystemStatus();
} else if (id.equals(RioConstants.CHANNEL_SYSALLON)) {
getProtocolHandler().refreshSystemAllOn();
} else if (id.equals(RioConstants.CHANNEL_SYSCONTROLLERS)) {
refreshNamedHandler(gson, RioControllerHandler.class, RioConstants.CHANNEL_SYSCONTROLLERS);
} else if (id.equals(RioConstants.CHANNEL_SYSSOURCES)) {
refreshNamedHandler(gson, RioSourceHandler.class, RioConstants.CHANNEL_SYSSOURCES);
}
// Can't refresh any others...
}
/**
* {@inheritDoc}
*
* Initializes the handler. This initialization will read/validate the configuration, then will create the
* {@link SocketSession} and will attempt to connect via {@link #connect()}.
*/
@Override
public void initialize() {
final RioSystemConfig rioConfig = getRioConfig();
if (rioConfig == null) {
return;
}
if (rioConfig.getIpAddress() == null || rioConfig.getIpAddress().trim().length() == 0) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"IP Address of Russound is missing from configuration");
return;
}
sessionLock.lock();
try {
session = new SocketChannelSession(rioConfig.getIpAddress(), RioConstants.RIO_PORT);
} finally {
sessionLock.unlock();
}
// Try initial connection in a scheduled task
this.scheduler.schedule(this::connect, 1, TimeUnit.SECONDS);
}
/**
* Attempts to connect to the system. If successfully connect, the {@link RioSystemProtocol#login()} will be
* called to log into the system (if needed). Once completed, a ping job will be created to keep the connection
* alive. If a connection cannot be established (or login failed), the connection attempt will be retried later (via
* {@link #retryConnect()})
*/
private void connect() {
String response = "Server is offline - will try to reconnect later";
sessionLock.lock();
pingLock.lock();
try {
session.connect();
final StatefulHandlerCallback callback = new StatefulHandlerCallback(new AbstractRioHandlerCallback() {
@Override
public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) {
updateStatus(status, detail, msg);
if (status != ThingStatus.ONLINE) {
disconnect();
reconnect();
}
}
@Override
public void stateChanged(String channelId, State state) {
updateState(channelId, state);
fireStateUpdated(channelId, state);
}
@Override
public void setProperty(String propertyName, String propertyValue) {
getThing().setProperty(propertyName, propertyValue);
}
});
setProtocolHandler(new RioSystemProtocol(session, callback));
favoritesProtocol.set(new RioSystemFavoritesProtocol(session, callback));
presetsProtocol.set(new RioPresetsProtocol(session, callback));
response = getProtocolHandler().login();
if (response == null) {
final RioSystemConfig rioConfig = getRioConfig();
if (rioConfig != null) {
ping = this.scheduler.scheduleWithFixedDelay(() -> {
try {
final ThingStatus status = getThing().getStatus();
if (status == ThingStatus.ONLINE) {
if (session.isConnected()) {
getProtocolHandler().ping();
}
}
} catch (Exception e) {
logger.error("Exception while pinging: {}", e.getMessage(), e);
}
}, rioConfig.getPing(), rioConfig.getPing(), TimeUnit.SECONDS);
logger.debug("Going online!");
updateStatus(ThingStatus.ONLINE);
startScan(rioConfig);
refreshNamedHandler(gson, RioSourceHandler.class, RioConstants.CHANNEL_SYSSOURCES);
refreshNamedHandler(gson, RioControllerHandler.class, RioConstants.CHANNEL_SYSCONTROLLERS);
return;
} else {
logger.debug("getRioConfig returned a null!");
}
} else {
logger.warn("Login return {}", response);
}
} catch (Exception e) {
logger.error("Error connecting: {}", e.getMessage(), e);
// do nothing
} finally {
pingLock.unlock();
sessionLock.unlock();
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, response);
reconnect();
}
/**
* Retries the connection attempt - schedules a job in {@link RioSystemConfig#getRetryPolling()} seconds to
* call the {@link #connect()} method. If a retry attempt is pending, the request is ignored.
*/
@Override
protected void reconnect() {
retryConnectionLock.lock();
try {
if (retryConnection == null) {
final RioSystemConfig rioConfig = getRioConfig();
if (rioConfig != null) {
logger.info("Will try to reconnect in {} seconds", rioConfig.getRetryPolling());
retryConnection = this.scheduler.schedule(() -> {
retryConnection = null;
try {
if (getThing().getStatus() != ThingStatus.ONLINE) {
connect();
}
} catch (Exception e) {
logger.error("Exception connecting: {}", e.getMessage(), e);
}
}, rioConfig.getRetryPolling(), TimeUnit.SECONDS);
}
} else {
logger.debug("RetryConnection called when a retry connection is pending - ignoring request");
}
} finally {
retryConnectionLock.unlock();
}
}
/**
* {@inheritDoc}
*
* Attempts to disconnect from the session. The protocol handler will be set to null, the {@link #ping} will be
* cancelled/set to null and the {@link #session} will be disconnected
*/
@Override
protected void disconnect() {
// Cancel ping
pingLock.lock();
try {
if (ping != null) {
ping.cancel(true);
ping = null;
}
} finally {
pingLock.unlock();
}
if (getProtocolHandler() != null) {
getProtocolHandler().watchSystem(false);
setProtocolHandler(null);
}
sessionLock.lock();
try {
session.disconnect();
} catch (IOException e) {
// ignore - we don't care
} finally {
sessionLock.unlock();
}
}
/**
* Simple gets the {@link RioSystemConfig} from the {@link Thing} and will set the status to offline if not
* found.
*
* @return a possible null {@link RioSystemConfig}
*/
public RioSystemConfig getRioConfig() {
configLock.lock();
try {
final RioSystemConfig sysConfig = getThing().getConfiguration().as(RioSystemConfig.class);
if (sysConfig == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing");
} else {
config = sysConfig;
}
return config;
} finally {
configLock.unlock();
}
}
/**
* Registers the {@link RioSystemDeviceDiscoveryService} with this handler. The discovery service will be called in
* {@link #startScan(RioSystemConfig)} when a device should be scanned and 'things' discovered from it
*
* @param service a possibly null {@link RioSystemDeviceDiscoveryService}
*/
public void registerDiscoveryService(RioSystemDeviceDiscoveryService service) {
discoveryService.set(service);
}
/**
* Helper method to possibly start a scan. A scan will ONLY be started if the {@link RioSystemConfig#isScanDevice()}
* is true and a discovery service has been set ({@link #registerDiscoveryService(RioSystemDeviceDiscoveryService)})
*
* @param sysConfig a non-null {@link RioSystemConfig}
*/
private void startScan(RioSystemConfig sysConfig) {
final RioSystemDeviceDiscoveryService service = discoveryService.get();
if (service != null) {
if (sysConfig != null && sysConfig.isScanDevice()) {
this.scheduler.execute(() -> {
logger.info("Starting device discovery");
service.scanDevice();
});
}
}
}
/**
* Overrides the base to call {@link #childChanged(ThingHandler)} to recreate the sources/controllers names
*/
@Override
public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
childChanged(childHandler, true);
}
/**
* Overrides the base to call {@link #childChanged(ThingHandler)} to recreate the sources/controllers names
*/
@Override
public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
childChanged(childHandler, false);
}
/**
* Helper method to recreate the {@link RioConstants#CHANNEL_SYSSOURCES} &&
* {@link RioConstants#CHANNEL_SYSCONTROLLERS} channels
*
* @param childHandler a non-null child handler that changed
* @param added true if added, false otherwise
* @throw IllegalArgumentException if childHandler is null
*/
private void childChanged(ThingHandler childHandler, boolean added) {
if (childHandler == null) {
throw new IllegalArgumentException("childHandler cannot be null");
}
if (childHandler instanceof RioSourceHandler) {
final RioHandlerCallback callback = ((RioSourceHandler) childHandler).getRioHandlerCallback();
if (callback != null) {
if (added) {
callback.addListener(RioConstants.CHANNEL_SOURCENAME, handlerCallbackListener);
} else {
callback.removeListener(RioConstants.CHANNEL_SOURCENAME, handlerCallbackListener);
}
}
refreshNamedHandler(gson, RioSourceHandler.class, RioConstants.CHANNEL_SYSSOURCES);
} else if (childHandler instanceof RioControllerHandler) {
refreshNamedHandler(gson, RioControllerHandler.class, RioConstants.CHANNEL_SYSCONTROLLERS);
}
}
/**
* Returns the {@link RioSystemFavoritesProtocol} for the system
*
* @return a possibly null {@link RioSystemFavoritesProtocol}
*/
@Override
public RioSystemFavoritesProtocol getSystemFavoritesHandler() {
return favoritesProtocol.get();
}
/**
* Returns the {@link RioPresetsProtocol} for the system
*
* @return a possibly null {@link RioPresetsProtocol}
*/
@Override
public RioPresetsProtocol getPresetsProtocol() {
return presetsProtocol.get();
}
}

View File

@@ -0,0 +1,280 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.system;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.binding.russound.internal.rio.AbstractRioProtocol;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This is the protocol handler for the Russound System. This handler will issue the protocol commands and will
* process the responses from the Russound system.
*
* @author Tim Roberts - Initial contribution
*/
class RioSystemProtocol extends AbstractRioProtocol {
// Logger
private final Logger logger = LoggerFactory.getLogger(RioSystemProtocol.class);
// Protocol Constants
private static final String SYS_VERSION = "version"; // 12 max
private static final String SYS_STATUS = "status"; // 12 max
private static final String SYS_LANG = "language"; // 12 max
// Response patterns
private static final Pattern RSP_VERSION = Pattern.compile("(?i)^S VERSION=\"(.+)\"$");
private static final Pattern RSP_FAILURE = Pattern.compile("(?i)^E (.*)");
private static final Pattern RSP_SYSTEMNOTIFICATION = Pattern.compile("(?i)^[SN] System\\.(\\w+)=\"(.*)\"$");
// all on state (there is no corresponding value)
private final AtomicBoolean allOn = new AtomicBoolean(false);
/**
* This represents our ping command. There is no ping command in the protocol so we simply send an empty command to
* keep things alive (and not generate any errors)
*/
private static final String CMD_PING = "";
/**
* Constructs the protocol handler from given parameters
*
* @param session a non-null {@link SocketSession} (may be connected or disconnected)
* @param callback a non-null {@link RioHandlerCallback} to callback
*/
RioSystemProtocol(SocketSession session, RioHandlerCallback callback) {
super(session, callback);
}
/**
* Attempts to log into the system. The russound system requires no login, so we immediately execute any
* {@link #postLogin()} commands.
*
* @return always null to indicate a successful login
*/
String login() {
postLogin();
return null;
}
/**
* Post successful login stuff - mark us online, start watching the system and refresh some attributes
*/
private void postLogin() {
logger.info("Russound System now connected");
statusChanged(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
watchSystem(true);
refreshSystemStatus();
refreshVersion();
refreshSystemLanguage();
}
/**
* Pings the server with out ping command to keep the connection alive
*/
void ping() {
sendCommand(CMD_PING);
}
/**
* Refreshes the firmware version of the system
*/
void refreshVersion() {
sendCommand(SYS_VERSION);
}
/**
* Helper method to refresh a system keyname
*
* @param keyName a non-null, non-empty keyname
* @throws IllegalArgumentException if keyname is null or empty
*/
private void refreshSystemKey(String keyName) {
if (keyName == null || keyName.trim().length() == 0) {
throw new IllegalArgumentException("keyName cannot be null or empty");
}
sendCommand("GET System." + keyName);
}
/**
* Refresh the system status
*/
void refreshSystemAllOn() {
stateChanged(RioConstants.CHANNEL_SYSALLON, allOn.get() ? OnOffType.ON : OnOffType.OFF);
}
/**
* Refresh the system language
*/
void refreshSystemLanguage() {
refreshSystemKey(SYS_LANG);
}
/**
* Refresh the system status
*/
void refreshSystemStatus() {
refreshSystemKey(SYS_STATUS);
}
/**
* Turns on/off watching for system notifications
*
* @param on true to turn on, false to turn off
*/
void watchSystem(boolean on) {
sendCommand("WATCH SYSTEM " + (on ? "ON" : "OFF"));
}
/**
* Sets all zones on
*
* @param on true to turn all zones on, false otherwise
*/
void setSystemAllOn(boolean on) {
sendCommand("EVENT C[1].Z[1]!All" + (on ? "On" : "Off"));
allOn.set(on);
refreshSystemAllOn();
}
/**
* Sets the system language (currently can only be english, chinese or russian). Case does not matter - will be
* converted to uppercase for the system.
*
* @param language a non-null, non-empty language to set
* @throws IllegalArgumentException if language is null, empty or not (english, chinese or russian).
*/
void setSystemLanguage(String language) {
if (language == null || language.trim().length() == 0) {
throw new IllegalArgumentException("Language cannot be null or an empty string");
}
if ("|ENGLISH|CHINESE|RUSSIAN|".indexOf("|" + language + "|") == -1) {
throw new IllegalArgumentException("Language can only be ENGLISH, CHINESE or RUSSIAN: " + language);
}
sendCommand("SET System." + SYS_LANG + " " + language.toUpperCase());
}
/**
* Handles the version notification
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
void handleVersionNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 1) {
final String version = m.group(1);
setProperty(RioConstants.PROPERTY_SYSVERSION, version);
} else {
logger.warn("Invalid System Notification response: '{}'", resp);
}
}
/**
* Handles any system notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
void handleSystemNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 2) {
final String key = m.group(1).toLowerCase();
final String value = m.group(2);
switch (key) {
case SYS_LANG:
stateChanged(RioConstants.CHANNEL_SYSLANG, new StringType(value));
break;
case SYS_STATUS:
stateChanged(RioConstants.CHANNEL_SYSSTATUS, "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
break;
default:
logger.warn("Unknown system notification: '{}'", resp);
break;
}
} else {
logger.warn("Invalid System Notification response: '{}'", resp);
}
}
/**
* Handles any error notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
private void handleFailureNotification(Matcher m, String resp) {
logger.debug("Error notification: {}", resp);
}
/**
* Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
* russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
*
* @param a possibly null, possibly empty response
*/
@Override
public void responseReceived(String response) {
if (StringUtils.isEmpty(response)) {
return;
}
Matcher m = RSP_VERSION.matcher(response);
if (m.matches()) {
handleVersionNotification(m, response);
return;
}
m = RSP_SYSTEMNOTIFICATION.matcher(response);
if (m.matches()) {
handleSystemNotification(m, response);
return;
}
m = RSP_FAILURE.matcher(response);
if (m.matches()) {
handleFailureNotification(m, response);
return;
}
}
/**
* Overrides the default implementation to turn watch off ({@link #watchSystem(boolean)}) before calling the dispose
*/
@Override
public void dispose() {
watchSystem(false);
super.dispose();
}
}

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.zone;
/**
* Configuration class for the {@link RioZoneHandler}
*
* @author Tim Roberts - Initial contribution
*/
public class RioZoneConfig {
/**
* Constant defined for the "zone" configuration field
*/
public static final String ZONE = "zone";
/**
* ID of the zone
*/
private int zone;
/**
* Gets the zone identifier
*
* @return the zone identifier
*/
public int getZone() {
return zone;
}
/**
* Sets the zone identifier
*
* @param zoneId the zone identifier
*/
public void setZone(int zoneId) {
this.zone = zoneId;
}
}

View File

@@ -0,0 +1,520 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.zone;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.rio.AbstractBridgeHandler;
import org.openhab.binding.russound.internal.rio.AbstractRioHandlerCallback;
import org.openhab.binding.russound.internal.rio.AbstractThingHandler;
import org.openhab.binding.russound.internal.rio.RioCallbackHandler;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
import org.openhab.binding.russound.internal.rio.RioNamedHandler;
import org.openhab.binding.russound.internal.rio.RioPresetsProtocol;
import org.openhab.binding.russound.internal.rio.RioSystemFavoritesProtocol;
import org.openhab.binding.russound.internal.rio.StatefulHandlerCallback;
import org.openhab.binding.russound.internal.rio.controller.RioControllerHandler;
import org.openhab.core.library.types.DecimalType;
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.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The bridge handler for a Russound Zone. A zone is the main receiving area for music. This implementation must be
* attached to a {@link RioControllerHandler} bridge.
*
* @author Tim Roberts - Initial contribution
*/
public class RioZoneHandler extends AbstractThingHandler<RioZoneProtocol>
implements RioNamedHandler, RioCallbackHandler {
// Logger
private final Logger logger = LoggerFactory.getLogger(RioZoneHandler.class);
/**
* The controller identifier we are attached to
*/
private final AtomicInteger controller = new AtomicInteger(0);
/**
* The zone identifier for this instance
*/
private final AtomicInteger zone = new AtomicInteger(0);
/**
* The zone name for this instance
*/
private final AtomicReference<String> zoneName = new AtomicReference<>(null);
/**
* Constructs the handler from the {@link Thing}
*
* @param thing a non-null {@link Thing} the handler is for
*/
public RioZoneHandler(Thing thing) {
super(thing);
}
/**
* Returns the controller identifier
*
* @return the controller identifier
*/
public int getController() {
return controller.get();
}
/**
* Returns the zone identifier
*
* @return the zone identifier
*/
@Override
public int getId() {
return zone.get();
}
/**
* Returns the zone name
*
* @return the zone name
*/
@Override
public String getName() {
final String name = zoneName.get();
return StringUtils.isEmpty(name) ? ("Zone " + getId()) : name;
}
/**
* {@inheritDoc}
*
* Handles commands to specific channels. This implementation will offload much of its work to the
* {@link RioZoneProtocol}. Basically we validate the type of command for the channel then call the
* {@link RioZoneProtocol} to handle the actual protocol. Special use case is the {@link RefreshType}
* where we call {{@link #handleRefresh(String)} to handle a refresh of the specific channel (which in turn calls
* {@link RioZoneProtocol} to handle the actual refresh
*/
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
handleRefresh(channelUID.getId());
return;
}
// if (getThing().getStatus() != ThingStatus.ONLINE) {
// // Ignore any command if not online
// return;
// }
String id = channelUID.getId();
if (id == null) {
logger.debug("Called with a null channel id - ignoring");
return;
}
if (id.equals(RioConstants.CHANNEL_ZONEBASS)) {
if (command instanceof DecimalType) {
getProtocolHandler().setZoneBass(((DecimalType) command).intValue());
} else {
logger.debug("Received a ZONE BASS channel command with a non DecimalType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONETREBLE)) {
if (command instanceof DecimalType) {
getProtocolHandler().setZoneTreble(((DecimalType) command).intValue());
} else {
logger.debug("Received a ZONE TREBLE channel command with a non DecimalType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEBALANCE)) {
if (command instanceof DecimalType) {
getProtocolHandler().setZoneBalance(((DecimalType) command).intValue());
} else {
logger.debug("Received a ZONE BALANCE channel command with a non DecimalType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONETURNONVOLUME)) {
if (command instanceof PercentType) {
getProtocolHandler().setZoneTurnOnVolume(((PercentType) command).intValue() / 100d);
} else if (command instanceof DecimalType) {
getProtocolHandler().setZoneTurnOnVolume(((DecimalType) command).doubleValue());
} else {
logger.debug("Received a ZONE TURN ON VOLUME channel command with a non PercentType/DecimalType: {}",
command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONELOUDNESS)) {
if (command instanceof OnOffType) {
getProtocolHandler().setZoneLoudness(command == OnOffType.ON);
} else {
logger.debug("Received a ZONE TURN ON VOLUME channel command with a non OnOffType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONESLEEPTIMEREMAINING)) {
if (command instanceof DecimalType) {
getProtocolHandler().setZoneSleepTimeRemaining(((DecimalType) command).intValue());
} else {
logger.debug("Received a ZONE SLEEP TIME REMAINING channel command with a non DecimalType: {}",
command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONESOURCE)) {
if (command instanceof DecimalType) {
getProtocolHandler().setZoneSource(((DecimalType) command).intValue());
} else {
logger.debug("Received a ZONE SOURCE channel command with a non DecimalType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONESTATUS)) {
if (command instanceof OnOffType) {
getProtocolHandler().setZoneStatus(command == OnOffType.ON);
} else {
logger.debug("Received a ZONE STATUS channel command with a non OnOffType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEPARTYMODE)) {
if (command instanceof StringType) {
getProtocolHandler().setZonePartyMode(((StringType) command).toString());
} else {
logger.debug("Received a ZONE PARTY MODE channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEDONOTDISTURB)) {
if (command instanceof StringType) {
getProtocolHandler().setZoneDoNotDisturb(((StringType) command).toString());
} else {
logger.debug("Received a ZONE DO NOT DISTURB channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEMUTE)) {
if (command instanceof OnOffType) {
getProtocolHandler().toggleZoneMute();
} else {
logger.debug("Received a ZONE MUTE channel command with a non OnOffType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEREPEAT)) {
if (command instanceof OnOffType) {
getProtocolHandler().toggleZoneRepeat();
} else {
logger.debug("Received a ZONE REPEAT channel command with a non OnOffType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONESHUFFLE)) {
if (command instanceof OnOffType) {
getProtocolHandler().toggleZoneShuffle();
} else {
logger.debug("Received a ZONE SHUFFLE channel command with a non OnOffType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEVOLUME)) {
if (command instanceof OnOffType) {
getProtocolHandler().setZoneStatus(command == OnOffType.ON);
} else if (command instanceof IncreaseDecreaseType) {
getProtocolHandler().setZoneVolume(command == IncreaseDecreaseType.INCREASE);
} else if (command instanceof PercentType) {
getProtocolHandler().setZoneVolume(((PercentType) command).intValue() / 100d);
} else if (command instanceof DecimalType) {
getProtocolHandler().setZoneVolume(((DecimalType) command).doubleValue());
} else {
logger.debug(
"Received a ZONE VOLUME channel command with a non OnOffType/IncreaseDecreaseType/PercentType/DecimalTye: {}",
command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONERATING)) {
if (command instanceof OnOffType) {
getProtocolHandler().setZoneRating(command == OnOffType.ON);
} else {
logger.debug("Received a ZONE RATING channel command with a non OnOffType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEKEYPRESS)) {
if (command instanceof StringType) {
getProtocolHandler().sendKeyPress(((StringType) command).toString());
} else {
logger.debug("Received a ZONE KEYPRESS channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEKEYRELEASE)) {
if (command instanceof StringType) {
getProtocolHandler().sendKeyRelease(((StringType) command).toString());
} else {
logger.debug("Received a ZONE KEYRELEASE channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEKEYHOLD)) {
if (command instanceof StringType) {
getProtocolHandler().sendKeyHold(((StringType) command).toString());
} else {
logger.debug("Received a ZONE KEYHOLD channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEKEYCODE)) {
if (command instanceof StringType) {
getProtocolHandler().sendKeyCode(((StringType) command).toString());
} else {
logger.debug("Received a ZONE KEYCODE channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEEVENT)) {
if (command instanceof StringType) {
getProtocolHandler().sendEvent(((StringType) command).toString());
} else {
logger.debug("Received a ZONE EVENT channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEMMINIT)) {
getProtocolHandler().sendMMInit();
} else if (id.equals(RioConstants.CHANNEL_ZONEMMCONTEXTMENU)) {
getProtocolHandler().sendMMContextMenu();
} else if (id.equals(RioConstants.CHANNEL_ZONESYSFAVORITES)) {
if (command instanceof StringType) {
// Remove any state for this channel to ensure it's recreated/sent again
// (clears any bad or deleted favorites information from the channel)
((StatefulHandlerCallback) getProtocolHandler().getCallback())
.removeState(RioConstants.CHANNEL_ZONESYSFAVORITES);
getProtocolHandler().setSystemFavorites(command.toString());
} else {
logger.debug("Received a SYSTEM FAVORITES channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEFAVORITES)) {
if (command instanceof StringType) {
// Remove any state for this channel to ensure it's recreated/sent again
// (clears any bad or deleted favorites information from the channel)
((StatefulHandlerCallback) getProtocolHandler().getCallback())
.removeState(RioConstants.CHANNEL_ZONEFAVORITES);
// schedule the returned callback in the future (to allow the channel to process and to allow russound
// to process (before re-retrieving information)
scheduler.schedule(getProtocolHandler().setZoneFavorites(command.toString()), 250,
TimeUnit.MILLISECONDS);
} else {
logger.debug("Received a ZONE FAVORITES channel command with a non StringType: {}", command);
}
} else if (id.equals(RioConstants.CHANNEL_ZONEPRESETS)) {
if (command instanceof StringType) {
((StatefulHandlerCallback) getProtocolHandler().getCallback())
.removeState(RioConstants.CHANNEL_ZONEPRESETS);
getProtocolHandler().setZonePresets(command.toString());
} else {
logger.debug("Received a ZONE FAVORITES channel command with a non StringType: {}", command);
}
} else {
logger.debug("Unknown/Unsupported Channel id: {}", id);
}
}
/**
* Method that handles the {@link RefreshType} command specifically. Calls the {@link RioZoneProtocol} to
* handle the actual refresh based on the channel id.
*
* @param id a non-null, possibly empty channel id to refresh
*/
private void handleRefresh(String id) {
if (getThing().getStatus() != ThingStatus.ONLINE) {
return;
}
if (getProtocolHandler() == null) {
return;
}
// Remove the cache'd value to force a refreshed value
((StatefulHandlerCallback) getProtocolHandler().getCallback()).removeState(id);
if (id.equals(RioConstants.CHANNEL_ZONENAME)) {
getProtocolHandler().refreshZoneName();
} else if (id.startsWith(RioConstants.CHANNEL_ZONESOURCE)) {
getProtocolHandler().refreshZoneSource();
} else if (id.startsWith(RioConstants.CHANNEL_ZONEBASS)) {
getProtocolHandler().refreshZoneBass();
} else if (id.startsWith(RioConstants.CHANNEL_ZONETREBLE)) {
getProtocolHandler().refreshZoneTreble();
} else if (id.startsWith(RioConstants.CHANNEL_ZONEBALANCE)) {
getProtocolHandler().refreshZoneBalance();
} else if (id.startsWith(RioConstants.CHANNEL_ZONELOUDNESS)) {
getProtocolHandler().refreshZoneLoudness();
} else if (id.startsWith(RioConstants.CHANNEL_ZONETURNONVOLUME)) {
getProtocolHandler().refreshZoneTurnOnVolume();
} else if (id.startsWith(RioConstants.CHANNEL_ZONEDONOTDISTURB)) {
getProtocolHandler().refreshZoneDoNotDisturb();
} else if (id.startsWith(RioConstants.CHANNEL_ZONEPARTYMODE)) {
getProtocolHandler().refreshZonePartyMode();
} else if (id.startsWith(RioConstants.CHANNEL_ZONESTATUS)) {
getProtocolHandler().refreshZoneStatus();
} else if (id.startsWith(RioConstants.CHANNEL_ZONEVOLUME)) {
getProtocolHandler().refreshZoneVolume();
} else if (id.startsWith(RioConstants.CHANNEL_ZONEMUTE)) {
getProtocolHandler().refreshZoneMute();
} else if (id.startsWith(RioConstants.CHANNEL_ZONEPAGE)) {
getProtocolHandler().refreshZonePage();
} else if (id.startsWith(RioConstants.CHANNEL_ZONESHAREDSOURCE)) {
getProtocolHandler().refreshZoneSharedSource();
} else if (id.startsWith(RioConstants.CHANNEL_ZONESLEEPTIMEREMAINING)) {
getProtocolHandler().refreshZoneSleepTimeRemaining();
} else if (id.startsWith(RioConstants.CHANNEL_ZONELASTERROR)) {
getProtocolHandler().refreshZoneLastError();
} else if (id.equals(RioConstants.CHANNEL_ZONESYSFAVORITES)) {
getProtocolHandler().refreshSystemFavorites();
} else if (id.equals(RioConstants.CHANNEL_ZONEFAVORITES)) {
getProtocolHandler().refreshZoneFavorites();
} else if (id.equals(RioConstants.CHANNEL_ZONEPRESETS)) {
getProtocolHandler().refreshZonePresets();
}
// Can't refresh any others...
}
/**
* Initializes the bridge. Confirms the configuration is valid and that our parent bridge is a
* {@link RioControllerHandler}. Once validated, a {@link RioZoneProtocol} is set via
* {@link #setProtocolHandler(RioZoneProtocol)} and the bridge comes online.
*/
@Override
public void initialize() {
final Bridge bridge = getBridge();
if (bridge == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Cannot be initialized without a bridge");
return;
}
if (bridge.getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
return;
}
final ThingHandler handler = bridge.getHandler();
if (handler == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No handler specified (null) for the bridge!");
return;
}
if (!(handler instanceof RioControllerHandler)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Source must be attached to a controller bridge: " + handler.getClass());
return;
}
final RioZoneConfig config = getThing().getConfiguration().as(RioZoneConfig.class);
if (config == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Configuration file missing");
return;
}
final int configZone = config.getZone();
if (configZone < 1 || configZone > 8) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Source must be between 1 and 8: " + configZone);
return;
}
zone.set(configZone);
final int handlerController = ((RioControllerHandler) handler).getId();
controller.set(handlerController);
// Get the socket session from the
final SocketSession socketSession = getSocketSession();
if (socketSession == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "No socket session found");
return;
}
setProtocolHandler(new RioZoneProtocol(configZone, handlerController, getSystemFavoritesHandler(),
getPresetsProtocol(), socketSession, new StatefulHandlerCallback(new AbstractRioHandlerCallback() {
@Override
public void statusChanged(ThingStatus status, ThingStatusDetail detail, String msg) {
updateStatus(status, detail, msg);
}
@Override
public void stateChanged(String channelId, State state) {
if (channelId.equals(RioConstants.CHANNEL_ZONENAME)) {
zoneName.set(state.toString());
}
updateState(channelId, state);
fireStateUpdated(channelId, state);
}
@Override
public void setProperty(String propertyName, String propertyValue) {
getThing().setProperty(propertyName, propertyValue);
}
})));
updateStatus(ThingStatus.ONLINE);
getProtocolHandler().postOnline();
}
/**
* Returns the {@link RioHandlerCallback} related to the zone
*
* @return a possibly null {@link RioHandlerCallback}
*/
@Override
public RioHandlerCallback getRioHandlerCallback() {
final RioZoneProtocol protocolHandler = getProtocolHandler();
return protocolHandler == null ? null : protocolHandler.getCallback();
}
/**
* Returns the {@link RioPresetsProtocol} related to the system. This simply queries the parent bridge for the
* protocol
*
* @return a possibly null {@link RioPresetsProtocol}
*/
@SuppressWarnings("rawtypes")
RioPresetsProtocol getPresetsProtocol() {
final Bridge bridge = getBridge();
if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) {
return ((AbstractBridgeHandler) bridge.getHandler()).getPresetsProtocol();
}
return null;
}
/**
* Returns the {@link RioSystemFavoritesProtocol} related to the system. This simply queries the parent bridge for
* the protocol
*
* @return a possibly null {@link RioSystemFavoritesProtocol}
*/
@SuppressWarnings("rawtypes")
RioSystemFavoritesProtocol getSystemFavoritesHandler() {
final Bridge bridge = getBridge();
if (bridge != null && bridge.getHandler() instanceof AbstractBridgeHandler) {
return ((AbstractBridgeHandler) bridge.getHandler()).getSystemFavoritesHandler();
}
return null;
}
}

View File

@@ -0,0 +1,950 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.russound.internal.rio.zone;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.russound.internal.net.SocketSession;
import org.openhab.binding.russound.internal.net.SocketSessionListener;
import org.openhab.binding.russound.internal.rio.AbstractRioProtocol;
import org.openhab.binding.russound.internal.rio.RioConstants;
import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
import org.openhab.binding.russound.internal.rio.RioPresetsProtocol;
import org.openhab.binding.russound.internal.rio.RioSystemFavoritesProtocol;
import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
import org.openhab.binding.russound.internal.rio.models.RioFavorite;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StringType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
/**
* This is the protocol handler for the Russound Zone. This handler will issue the protocol commands and will
* process the responses from the Russound system.
*
* @author Tim Roberts - Initial contribution
*/
class RioZoneProtocol extends AbstractRioProtocol
implements RioSystemFavoritesProtocol.Listener, RioPresetsProtocol.Listener {
// logger
private final Logger logger = LoggerFactory.getLogger(RioZoneProtocol.class);
/**
* The controller identifier
*/
private final int controller;
/**
* The zone identifier
*/
private final int zone;
// Zone constants
private static final String ZONE_NAME = "name"; // 12 max
private static final String ZONE_SOURCE = "currentsource"; // 1-8 or 1-12
private static final String ZONE_BASS = "bass"; // -10 to 10
private static final String ZONE_TREBLE = "treble"; // -10 to 10
private static final String ZONE_BALANCE = "balance"; // -10 to 10
private static final String ZONE_LOUDNESS = "loudness"; // OFF/ON
private static final String ZONE_TURNONVOLUME = "turnonvolume"; // 0 to 50
private static final String ZONE_DONOTDISTURB = "donotdisturb"; // OFF/ON/SLAVE
private static final String ZONE_PARTYMODE = "partymode"; // OFF/ON/MASTER
private static final String ZONE_STATUS = "status"; // OFF/ON/MASTER
private static final String ZONE_VOLUME = "volume"; // 0 to 50
private static final String ZONE_MUTE = "mute"; // OFF/ON/MASTER
private static final String ZONE_PAGE = "page"; // OFF/ON/MASTER
private static final String ZONE_SHAREDSOURCE = "sharedsource"; // OFF/ON/MASTER
private static final String ZONE_SLEEPTIMEREMAINING = "sleeptimeremaining"; // OFF/ON/MASTER
private static final String ZONE_LASTERROR = "lasterror"; // OFF/ON/MASTER
private static final String ZONE_ENABLED = "enabled"; // OFF/ON
// Multimedia functions
private static final String ZONE_MMINIT = "MMInit"; // button
private static final String ZONE_MMCONTEXTMENU = "MMContextMenu"; // button
// Favorites
private static final String FAV_NAME = "name";
private static final String FAV_VALID = "valid";
// Respone patterns
private static final Pattern RSP_ZONENOTIFICATION = Pattern
.compile("(?i)^[SN] C\\[(\\d+)\\]\\.Z\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
private static final Pattern RSP_ZONEFAVORITENOTIFICATION = Pattern
.compile("(?i)^[SN] C\\[(\\d+)\\].Z\\[(\\d+)\\].favorite\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
// The zone favorites
private final RioFavorite[] zoneFavorites = new RioFavorite[2];
// The current source identifier (or -1 if none)
private final AtomicInteger sourceId = new AtomicInteger(-1);
// GSON object used for json
private final Gson gson;
// The favorites protocol
private final RioSystemFavoritesProtocol favoritesProtocol;
// The presets protocol
private final RioPresetsProtocol presetsProtocol;
/**
* Constructs the protocol handler from given parameters
*
* @param zone the zone identifier
* @param controller the controller identifier
* @param favoritesProtocol a non-null {@link RioSystemFavoritesProtocol}
* @param presetsProtocol a non-null {@link RioPresetsProtocol}
* @param session a non-null {@link SocketSession} (may be connected or disconnected)
* @param callback a non-null {@link RioHandlerCallback} to callback
*/
RioZoneProtocol(int zone, int controller, RioSystemFavoritesProtocol favoritesProtocol,
RioPresetsProtocol presetsProtocol, SocketSession session, RioHandlerCallback callback) {
super(session, callback);
if (controller < 1 || controller > 6) {
throw new IllegalArgumentException("Controller must be between 1-6: " + controller);
}
if (zone < 1 || zone > 8) {
throw new IllegalArgumentException("Zone must be between 1-6: " + zone);
}
this.controller = controller;
this.zone = zone;
this.favoritesProtocol = favoritesProtocol;
this.favoritesProtocol.addListener(this);
this.presetsProtocol = presetsProtocol;
this.presetsProtocol.addListener(this);
this.gson = GsonUtilities.createGson();
this.zoneFavorites[0] = new RioFavorite(1);
this.zoneFavorites[1] = new RioFavorite(2);
}
/**
* Helper method to issue post online commands
*/
void postOnline() {
watchZone(true);
refreshZoneSource();
refreshZoneEnabled();
refreshZoneName();
systemFavoritesUpdated(favoritesProtocol.getJson());
}
/**
* Helper method to refresh a system keyname
*
* @param keyname a non-null, non-empty keyname
* @throws IllegalArgumentException if keyname is null or empty
*/
private void refreshZoneKey(String keyname) {
if (keyname == null || keyname.trim().length() == 0) {
throw new IllegalArgumentException("keyName cannot be null or empty");
}
sendCommand("GET C[" + controller + "].Z[" + zone + "]." + keyname);
}
/**
* Refresh a zone name
*/
void refreshZoneName() {
refreshZoneKey(ZONE_NAME);
}
/**
* Refresh the zone's source
*/
void refreshZoneSource() {
refreshZoneKey(ZONE_SOURCE);
}
/**
* Refresh the zone's bass setting
*/
void refreshZoneBass() {
refreshZoneKey(ZONE_BASS);
}
/**
* Refresh the zone's treble setting
*/
void refreshZoneTreble() {
refreshZoneKey(ZONE_TREBLE);
}
/**
* Refresh the zone's balance setting
*/
void refreshZoneBalance() {
refreshZoneKey(ZONE_BALANCE);
}
/**
* Refresh the zone's loudness setting
*/
void refreshZoneLoudness() {
refreshZoneKey(ZONE_LOUDNESS);
}
/**
* Refresh the zone's turn on volume setting
*/
void refreshZoneTurnOnVolume() {
refreshZoneKey(ZONE_TURNONVOLUME);
}
/**
* Refresh the zone's do not disturb setting
*/
void refreshZoneDoNotDisturb() {
refreshZoneKey(ZONE_DONOTDISTURB);
}
/**
* Refresh the zone's party mode setting
*/
void refreshZonePartyMode() {
refreshZoneKey(ZONE_PARTYMODE);
}
/**
* Refresh the zone's status
*/
void refreshZoneStatus() {
refreshZoneKey(ZONE_STATUS);
}
/**
* Refresh the zone's volume setting
*/
void refreshZoneVolume() {
refreshZoneKey(ZONE_VOLUME);
}
/**
* Refresh the zone's mute setting
*/
void refreshZoneMute() {
refreshZoneKey(ZONE_MUTE);
}
/**
* Refresh the zone's paging setting
*/
void refreshZonePage() {
refreshZoneKey(ZONE_PAGE);
}
/**
* Refresh the zone's shared source setting
*/
void refreshZoneSharedSource() {
refreshZoneKey(ZONE_SHAREDSOURCE);
}
/**
* Refresh the zone's sleep time remaining setting
*/
void refreshZoneSleepTimeRemaining() {
refreshZoneKey(ZONE_SLEEPTIMEREMAINING);
}
/**
* Refresh the zone's last error
*/
void refreshZoneLastError() {
refreshZoneKey(ZONE_LASTERROR);
}
/**
* Refresh the zone's enabled setting
*/
void refreshZoneEnabled() {
refreshZoneKey(ZONE_ENABLED);
}
/**
* Refreshes the system favorites via {@link #favoritesProtocol}
*/
void refreshSystemFavorites() {
favoritesProtocol.refreshSystemFavorites();
}
/**
* Refreshes the zone favorites
*/
void refreshZoneFavorites() {
for (int x = 1; x <= 2; x++) {
sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + x + "].valid");
sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + x + "].name");
}
}
/**
* Refresh the zone preset via {@link #presetsProtocol}
*/
void refreshZonePresets() {
presetsProtocol.refreshPresets();
}
/**
* Turns on/off watching for zone notifications
*
* @param on true to turn on, false to turn off
*/
void watchZone(boolean watch) {
sendCommand("WATCH C[" + controller + "].Z[" + zone + "] " + (watch ? "ON" : "OFF"));
}
/**
* Set's the zone bass setting (from -10 to 10)
*
* @param bass the bass setting from -10 to 10
* @throws IllegalArgumentException if bass < -10 or > 10
*/
void setZoneBass(int bass) {
if (bass < -10 || bass > 10) {
throw new IllegalArgumentException("Bass must be between -10 and 10: " + bass);
}
sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_BASS + "=\"" + bass + "\"");
}
/**
* Set's the zone treble setting (from -10 to 10)
*
* @param treble the treble setting from -10 to 10
* @throws IllegalArgumentException if treble < -10 or > 10
*/
void setZoneTreble(int treble) {
if (treble < -10 || treble > 10) {
throw new IllegalArgumentException("Treble must be between -10 and 10: " + treble);
}
sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_TREBLE + "=\"" + treble + "\"");
}
/**
* Set's the zone balance setting (from -10 [full left] to 10 [full right])
*
* @param balance the balance setting from -10 to 10
* @throws IllegalArgumentException if balance < -10 or > 10
*/
void setZoneBalance(int balance) {
if (balance < -10 || balance > 10) {
throw new IllegalArgumentException("Balance must be between -10 and 10: " + balance);
}
sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_BALANCE + "=\"" + balance + "\"");
}
/**
* Set's the zone's loudness
*
* @param on true to turn on loudness, false to turn off
*/
void setZoneLoudness(boolean on) {
sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_LOUDNESS + "=\"" + (on ? "ON" : "OFF") + "\"");
}
/**
* Set's the zone turn on volume (will be scaled between 0 and 50)
*
* @param volume the turn on volume (between 0 and 1)
* @throws IllegalArgumentException if volume < 0 or > 1
*/
void setZoneTurnOnVolume(double volume) {
if (volume < 0 || volume > 1) {
throw new IllegalArgumentException("Volume must be between 0 and 1: " + volume);
}
final int scaledVolume = (int) ((volume * 100) / 2);
sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_TURNONVOLUME + "=\"" + scaledVolume + "\"");
}
/**
* Set's the zone sleep time remaining in seconds (from 0 to 60). Will be rounded to nearest 5 (37 will become 35,
* 38 will become 40).
*
* @param sleepTime the sleeptime in seconds
* @throws IllegalArgumentException if sleepTime < 0 or > 60
*/
void setZoneSleepTimeRemaining(int sleepTime) {
if (sleepTime < 0 || sleepTime > 60) {
throw new IllegalArgumentException("Sleep Time Remaining must be between 0 and 60: " + sleepTime);
}
sleepTime = (int) (5 * Math.round(sleepTime / 5.0));
sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_SLEEPTIMEREMAINING + "=\"" + sleepTime + "\"");
}
/**
* Set's the zone source (physical source from 1 to 12)
*
* @param source the source (1 to 12)
* @throws IllegalArgumentException if source is < 1 or > 12
*/
void setZoneSource(int source) {
if (source < 1 || source > 12) {
throw new IllegalArgumentException("Source must be between 1 and 12");
}
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!SelectSource " + source);
}
/**
* Set's the zone's status
*
* @param on true to turn on, false otherwise
*/
void setZoneStatus(boolean on) {
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Zone" + (on ? "On" : "Off"));
}
/**
* Set's the zone's partymode (supports on/off/master). Case does not matter - will be
* converted to uppercase for the system.
*
* @param partyMode a non-null, non-empty party mode
* @throws IllegalArgumentException if partymode is null, empty or not (on/off/master).
*/
void setZonePartyMode(String partyMode) {
if (partyMode == null || partyMode.trim().length() == 0) {
throw new IllegalArgumentException("PartyMode cannot be null or empty");
}
if ("|on|off|master|".indexOf("|" + partyMode + "|") == -1) {
throw new IllegalArgumentException(
"Party mode can only be set to on, off or master: " + partyMode.toUpperCase());
}
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!PartyMode " + partyMode);
}
/**
* Set's the zone's do not disturb (supports on/off/slave). Case does not matter - will be
* converted to uppercase for the system. Please note that slave will be translated to "ON" but may be refreshed
* back to "SLAVE" if a master zone has been designated
*
* @param doNotDisturb a non-null, non-empty do not disturb mode
* @throws IllegalArgumentException if doNotDisturb is null, empty or not (on/off/slave).
*/
void setZoneDoNotDisturb(String doNotDisturb) {
if (doNotDisturb == null || doNotDisturb.trim().length() == 0) {
throw new IllegalArgumentException("Do Not Disturb cannot be null or empty");
}
if ("|on|off|slave|".indexOf("|" + doNotDisturb + "|") == -1) {
throw new IllegalArgumentException("Do Not Disturb can only be set to on, off or slave: " + doNotDisturb);
}
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!DoNotDisturb "
+ ("off".equals(doNotDisturb) ? "OFF" : "ON")); // translate "slave" to "on"
}
/**
* Sets the zone's volume level (scaled to 0-50)
*
* @param volume the volume level
* @throws IllegalArgumentException if volume is < 0 or > 1
*/
void setZoneVolume(double volume) {
if (volume < 0 || volume > 1) {
throw new IllegalArgumentException("Volume must be between 0 and 1");
}
final int scaledVolume = (int) ((volume * 100) / 2);
sendKeyPress("Volume " + scaledVolume);
}
/**
* Sets the volume up or down by 1
*
* @param increase true to increase by 1, false to decrease
*/
void setZoneVolume(boolean increase) {
sendKeyPress("Volume" + (increase ? "Up" : "Down"));
}
/**
* Toggles the zone's mute
*/
void toggleZoneMute() {
sendKeyRelease("Mute");
}
/**
* Toggles the zone's shuffle if the source supports shuffle mode
*/
void toggleZoneShuffle() {
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Shuffle");
}
/**
* Toggles the zone's repeat if the source supports repeat mod
*/
void toggleZoneRepeat() {
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Repeat");
}
/**
* Assign a rating to the current song if the source supports a rating
*
* @param like true to like, false to dislike
*/
void setZoneRating(boolean like) {
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!MMRate " + (like ? "hi" : "low"));
}
/**
* Sets the system favorite based on what is currently being played in the zone via {@link #favoritesProtocol}
*
* @param favJson a possibly null, possibly empty JSON of favorites to set
*/
void setSystemFavorites(String favJson) {
favoritesProtocol.setSystemFavorites(controller, zone, favJson);
}
/**
* Sets the zone favorites to what is currently playing
*
* @param favJson a possibly null, possibly empty json for favorites to set
* @return a non-null {@link Runnable} that should be run after the call
*/
Runnable setZoneFavorites(String favJson) {
if (StringUtils.isEmpty(favJson)) {
return () -> {
};
}
final List<Integer> updateFavIds = new ArrayList<>();
try {
final RioFavorite[] favs = gson.fromJson(favJson, RioFavorite[].class);
for (int x = favs.length - 1; x >= 0; x--) {
final RioFavorite fav = favs[x];
if (fav == null) {
continue;// caused by {id,valid,name},,{id,valid,name}
}
final int favId = fav.getId();
if (favId < 1 || favId > 2) {
logger.debug("Invalid favorite id (not between 1 and 2) - ignoring: {}:{}", favId, favJson);
} else {
final RioFavorite myFav = zoneFavorites[favId - 1];
final boolean favValid = fav.isValid();
final String favName = fav.getName();
if (!StringUtils.equals(myFav.getName(), favName) || myFav.isValid() != favValid) {
myFav.setName(favName);
myFav.setValid(favValid);
if (favValid) {
sendEvent("saveZoneFavorite \"" + favName + "\" " + favId);
updateFavIds.add(favId);
} else {
sendEvent("deleteZoneFavorite " + favId);
}
}
}
}
} catch (JsonSyntaxException e) {
logger.debug("Invalid JSON: {}", e.getMessage(), e);
}
// regardless of what happens above - reupdate the channel
// (to remove anything bad from it)
return () -> {
for (Integer favId : updateFavIds) {
sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + favId + "].valid");
sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + favId + "].name");
}
updateZoneFavoritesChannel();
};
}
/**
* Sets the zone presets for what is currently playing via {@link #presetsProtocol}
*
* @param presetJson a possibly empty, possibly null preset json
*/
void setZonePresets(String presetJson) {
presetsProtocol.setZonePresets(controller, zone, sourceId.get(), presetJson);
}
/**
* Sends a KeyPress instruction to the zone
*
* @param keyPress a non-null, non-empty string to send
* @throws IllegalArgumentException if keyPress is null or empty
*/
void sendKeyPress(String keyPress) {
if (keyPress == null || keyPress.trim().length() == 0) {
throw new IllegalArgumentException("keyPress cannot be null or empty");
}
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyPress " + keyPress);
}
/**
* Sends a KeyRelease instruction to the zone
*
* @param keyRelease a non-null, non-empty string to send
* @throws IllegalArgumentException if keyRelease is null or empty
*/
void sendKeyRelease(String keyRelease) {
if (keyRelease == null || keyRelease.trim().length() == 0) {
throw new IllegalArgumentException("keyRelease cannot be null or empty");
}
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyRelease " + keyRelease);
}
/**
* Sends a KeyHold instruction to the zone
*
* @param keyHold a non-null, non-empty string to send
* @throws IllegalArgumentException if keyHold is null or empty
*/
void sendKeyHold(String keyHold) {
if (keyHold == null || keyHold.trim().length() == 0) {
throw new IllegalArgumentException("keyHold cannot be null or empty");
}
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyHold " + keyHold);
}
/**
* Sends a KeyCode instruction to the zone
*
* @param keyCode a non-null, non-empty string to send
* @throws IllegalArgumentException if keyCode is null or empty
*/
void sendKeyCode(String keyCode) {
if (keyCode == null || keyCode.trim().length() == 0) {
throw new IllegalArgumentException("keyCode cannot be null or empty");
}
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyCode " + keyCode);
}
/**
* Sends a EVENT instruction to the zone
*
* @param event a non-null, non-empty string to send
* @throws IllegalArgumentException if event is null or empty
*/
void sendEvent(String event) {
if (event == null || event.trim().length() == 0) {
throw new IllegalArgumentException("event cannot be null or empty");
}
sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!" + event);
}
/**
* Sends the MMInit [home screen] command
*/
void sendMMInit() {
sendEvent("MMVerbosity 2");
sendEvent("MMIndex ABSOLUTE");
sendEvent("MMFormat JSON");
sendEvent("MMUseBlockInfo TRUE");
sendEvent("MMUseForms FALSE");
sendEvent("MMMaxItems 25");
sendEvent(ZONE_MMINIT);
}
/**
* Requests a context menu
*/
void sendMMContextMenu() {
sendEvent("MMVerbosity 2");
sendEvent("MMIndex ABSOLUTE");
sendEvent("MMFormat JSON");
sendEvent("MMUseBlockInfo TRUE");
sendEvent("MMUseForms FALSE");
sendEvent("MMMaxItems 25");
sendEvent(ZONE_MMCONTEXTMENU);
}
/**
* Handles any zone notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
private void handleZoneNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 4) {
try {
final int notifyController = Integer.parseInt(m.group(1));
if (notifyController != controller) {
return;
}
final int notifyZone = Integer.parseInt(m.group(2));
if (notifyZone != zone) {
return;
}
final String key = m.group(3).toLowerCase();
final String value = m.group(4);
switch (key) {
case ZONE_NAME:
stateChanged(RioConstants.CHANNEL_ZONENAME, new StringType(value));
break;
case ZONE_SOURCE:
try {
final int nbr = Integer.parseInt(value);
stateChanged(RioConstants.CHANNEL_ZONESOURCE, new DecimalType(nbr));
if (nbr != sourceId.getAndSet(nbr)) {
sourceId.set(nbr);
presetsUpdated(nbr, presetsProtocol.getJson(nbr));
}
} catch (NumberFormatException e) {
logger.warn("Invalid zone notification (source not parsable): '{}')", resp);
}
break;
case ZONE_BASS:
try {
final int nbr = Integer.parseInt(value);
stateChanged(RioConstants.CHANNEL_ZONEBASS, new DecimalType(nbr));
} catch (NumberFormatException e) {
logger.warn("Invalid zone notification (bass not parsable): '{}')", resp);
}
break;
case ZONE_TREBLE:
try {
final int nbr = Integer.parseInt(value);
stateChanged(RioConstants.CHANNEL_ZONETREBLE, new DecimalType(nbr));
} catch (NumberFormatException e) {
logger.warn("Invalid zone notification (treble not parsable): '{}')", resp);
}
break;
case ZONE_BALANCE:
try {
final int nbr = Integer.parseInt(value);
stateChanged(RioConstants.CHANNEL_ZONEBALANCE, new DecimalType(nbr));
} catch (NumberFormatException e) {
logger.warn("Invalid zone notification (balance not parsable): '{}')", resp);
}
break;
case ZONE_LOUDNESS:
stateChanged(RioConstants.CHANNEL_ZONELOUDNESS,
"ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
break;
case ZONE_TURNONVOLUME:
try {
final int nbr = Integer.parseInt(value);
stateChanged(RioConstants.CHANNEL_ZONETURNONVOLUME, new PercentType(nbr * 2));
} catch (NumberFormatException e) {
logger.warn("Invalid zone notification (turnonvolume not parsable): '{}')", resp);
}
break;
case ZONE_DONOTDISTURB:
stateChanged(RioConstants.CHANNEL_ZONEDONOTDISTURB, new StringType(value));
break;
case ZONE_PARTYMODE:
stateChanged(RioConstants.CHANNEL_ZONEPARTYMODE, new StringType(value));
break;
case ZONE_STATUS:
stateChanged(RioConstants.CHANNEL_ZONESTATUS,
"ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
break;
case ZONE_MUTE:
stateChanged(RioConstants.CHANNEL_ZONEMUTE, "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
break;
case ZONE_SHAREDSOURCE:
stateChanged(RioConstants.CHANNEL_ZONESHAREDSOURCE,
"ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
break;
case ZONE_LASTERROR:
stateChanged(RioConstants.CHANNEL_ZONELASTERROR, new StringType(value));
break;
case ZONE_PAGE:
stateChanged(RioConstants.CHANNEL_ZONEPAGE, "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
break;
case ZONE_SLEEPTIMEREMAINING:
try {
final int nbr = Integer.parseInt(value);
stateChanged(RioConstants.CHANNEL_ZONESLEEPTIMEREMAINING, new DecimalType(nbr));
} catch (NumberFormatException e) {
logger.warn("Invalid zone notification (sleeptimeremaining not parsable): '{}')", resp);
}
break;
case ZONE_ENABLED:
stateChanged(RioConstants.CHANNEL_ZONEENABLED,
"ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
break;
case ZONE_VOLUME:
try {
final int nbr = Integer.parseInt(value);
stateChanged(RioConstants.CHANNEL_ZONEVOLUME, new PercentType(nbr * 2));
} catch (NumberFormatException e) {
logger.warn("Invalid zone notification (volume not parsable): '{}')", resp);
}
break;
default:
logger.warn("Unknown zone notification: '{}'", resp);
break;
}
} catch (NumberFormatException e) {
logger.warn("Invalid Zone Notification (controller/zone not a parsable integer): '{}')", resp);
}
} else {
logger.warn("Invalid Zone Notification response: '{}'", resp);
}
}
/**
* Handles any system notifications returned by the russound system
*
* @param m a non-null matcher
* @param resp a possibly null, possibly empty response
*/
void handleZoneFavoriteNotification(Matcher m, String resp) {
if (m == null) {
throw new IllegalArgumentException("m (matcher) cannot be null");
}
if (m.groupCount() == 5) {
try {
final int notifyController = Integer.parseInt(m.group(1));
if (notifyController != controller) {
return;
}
final int notifyZone = Integer.parseInt(m.group(2));
if (notifyZone != zone) {
return;
}
final int favoriteId = Integer.parseInt(m.group(3));
if (favoriteId >= 1 && favoriteId <= 2) {
final RioFavorite fav = zoneFavorites[favoriteId - 1];
final String key = m.group(4);
final String value = m.group(5);
switch (key) {
case FAV_NAME:
fav.setName(value);
updateZoneFavoritesChannel();
break;
case FAV_VALID:
fav.setValid(!"false".equalsIgnoreCase(value));
updateZoneFavoritesChannel();
break;
default:
logger.warn("Unknown zone favorite notification: '{}'", resp);
break;
}
} else {
logger.warn("Invalid Zone Favorite Notification (favorite < 1 or > 2): '{}')", resp);
}
} catch (NumberFormatException e) {
logger.warn("Invalid Zone Favorite Notification (favorite not a parsable integer): '{}')", resp);
}
} else {
logger.warn("Invalid Zone Notification response: '{}'", resp);
}
}
/**
* Will update the zone favorites channel with only valid favorites
*/
private void updateZoneFavoritesChannel() {
final List<RioFavorite> favs = new ArrayList<>();
for (final RioFavorite fav : zoneFavorites) {
if (fav.isValid()) {
favs.add(fav);
}
}
final String favJson = gson.toJson(favs);
stateChanged(RioConstants.CHANNEL_ZONEFAVORITES, new StringType(favJson));
}
/**
* Callback method when system favorites are updated. Simply issues a state change for the zone system favorites
* channel using the jsonString as the value
*/
@Override
public void systemFavoritesUpdated(String jsonString) {
stateChanged(RioConstants.CHANNEL_ZONESYSFAVORITES, new StringType(jsonString));
}
/**
* Callback method when presets are updated. Simply issues a state change for the zone presets channel using the
* jsonString as the value
*/
@Override
public void presetsUpdated(int sourceIdUpdated, String jsonString) {
if (sourceIdUpdated != sourceId.get()) {
return;
}
stateChanged(RioConstants.CHANNEL_ZONEPRESETS, new StringType(jsonString));
}
/**
* Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
* russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
*
* @param a possibly null, possibly empty response
*/
@Override
public void responseReceived(String response) {
if (StringUtils.isEmpty(response)) {
return;
}
Matcher m = RSP_ZONENOTIFICATION.matcher(response);
if (m.matches()) {
handleZoneNotification(m, response);
}
m = RSP_ZONEFAVORITENOTIFICATION.matcher(response);
if (m.matches()) {
handleZoneFavoriteNotification(m, response);
}
}
/**
* Overrides the default implementation to turn watch off ({@link #watchZone(boolean)}) before calling the dispose
*/
@Override
public void dispose() {
watchZone(false);
favoritesProtocol.removeListener(this);
super.dispose();
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="russound" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>Russound Binding</name>
<description>This is the binding for Russound Whole House Audio systems (MCA and X-Systems).</description>
<author>Tim Roberts</author>
</binding:binding>

View File

@@ -0,0 +1,569 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="russound"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<bridge-type id="rio">
<label>Russound RIO Device</label>
<description>Ethernet access point to Russound RIO control system (usually the main controller)</description>
<channels>
<channel id="lang" typeId="sysLang"/>
<channel id="allon" typeId="sysAllOn"/>
<channel id="controllers" typeId="sysControllers"/>
<channel id="sources" typeId="sysSources"/>
</channels>
<config-description>
<parameter name="ipAddress" type="text" required="true">
<context>network-address</context>
<label>IP or Host Name</label>
<description>The IP or host name of the Russound RIO access point</description>
</parameter>
<parameter name="ping" type="integer" required="false">
<label>Ping Interval</label>
<description>The ping interval in seconds to keep the connection alive</description>
<default>30</default>
<advanced>true</advanced>
</parameter>
<parameter name="retryPolling" type="integer" required="false">
<label>Retry Polling</label>
<description>The polling, in seconds, to retry a connection attempt</description>
<default>10</default>
<advanced>true</advanced>
</parameter>
<parameter name="scanDevice" type="boolean" required="false">
<label>Scan Device</label>
<description>Scan device at startup (creating zones, sources, etc dynamically)</description>
<default>false</default>
<advanced>true</advanced>
</parameter>
</config-description>
</bridge-type>
<bridge-type id="controller">
<supported-bridge-type-refs>
<bridge-type-ref id="rio"/>
</supported-bridge-type-refs>
<label>Russound Controller</label>
<description>Controller of Zones, Sources, etc</description>
<channels>
<channel id="zones" typeId="ctlZones"/>
</channels>
<config-description>
<parameter name="controller" type="integer" min="1" max="6" required="true">
<label>Controller ID</label>
<description>The controller identifier</description>
</parameter>
</config-description>
</bridge-type>
<thing-type id="zone">
<supported-bridge-type-refs>
<bridge-type-ref id="controller"/>
</supported-bridge-type-refs>
<label>Russound Zone</label>
<description>Zone within a Controller</description>
<channels>
<channel id="name" typeId="zoneName"/>
<channel id="source" typeId="zoneSource"/>
<channel id="bass" typeId="zoneBass"/>
<channel id="treble" typeId="zoneTreble"/>
<channel id="balance" typeId="zoneBalance"/>
<channel id="loudness" typeId="zoneLoudness"/>
<channel id="turnonvolume" typeId="zoneTurnOnVolume"/>
<channel id="donotdisturb" typeId="zoneDoNotDisturb"/>
<channel id="partymode" typeId="zonePartyMode"/>
<channel id="status" typeId="zoneStatus"/>
<channel id="volume" typeId="zoneVolume"/>
<channel id="mute" typeId="zoneMute"/>
<channel id="page" typeId="zonePage"/>
<channel id="sharedsource" typeId="zoneSharedSource"/>
<channel id="sleeptimeremaining" typeId="zoneSleepTimeRemaining"/>
<channel id="lasterror" typeId="zoneLastError"/>
<channel id="enabled" typeId="zoneEnabled"/>
<channel id="repeat" typeId="zoneRepeat"/>
<channel id="shuffle" typeId="zoneShuffle"/>
<channel id="rating" typeId="zoneRating"/>
<channel id="keypress" typeId="zoneKeyPress"/>
<channel id="keyrelease" typeId="zoneKeyRelease"/>
<channel id="keyhold" typeId="zoneKeyHold"/>
<channel id="keycode" typeId="zoneKeyCode"/>
<channel id="event" typeId="zoneEvent"/>
<channel id="systemfavorites" typeId="zoneSysFavorites"/>
<channel id="zonefavorites" typeId="zoneFavorites"/>
<channel id="presets" typeId="zonePresets"/>
<channel id="mminit" typeId="zoneMMInit"/>
<channel id="mmcontextmenu" typeId="zoneMMContextMenu"/>
</channels>
<config-description>
<parameter name="zone" type="integer" min="1" max="8" required="true">
<label>Zone ID</label>
<description>The zone identifier</description>
</parameter>
</config-description>
</thing-type>
<thing-type id="source">
<supported-bridge-type-refs>
<bridge-type-ref id="rio"/>
</supported-bridge-type-refs>
<label>Russound Source</label>
<description>Source (tuner, streamer, etc) within the Russound System</description>
<channels>
<channel id="name" typeId="srcName"/>
<channel id="type" typeId="srcType"/>
<channel id="channel" typeId="srcChannel"/>
<channel id="channelname" typeId="srcChannelName"/>
<channel id="composername" typeId="srcComposerName"/>
<channel id="genre" typeId="srcGenre"/>
<channel id="artistname" typeId="srcArtistName"/>
<channel id="albumname" typeId="srcAlbumName"/>
<channel id="coverarturl" typeId="srcCoverArtUrl"/>
<channel id="playlistname" typeId="srcPlayListName"/>
<channel id="songname" typeId="srcSongName"/>
<channel id="rating" typeId="srcRating"/>
<channel id="mode" typeId="srcMode"/>
<channel id="shufflemode" typeId="srcShuffleMode"/>
<channel id="repeatmode" typeId="srcRepeatMode"/>
<channel id="programservicename" typeId="srcProgramServiceName"/>
<channel id="radiotext" typeId="srcRadioText"/>
<channel id="radiotext2" typeId="srcRadioText2"/>
<channel id="radiotext3" typeId="srcRadioText3"/>
<channel id="radiotext4" typeId="srcRadioText4"/>
<channel id="volume" typeId="srcVolume"/>
<channel id="banks" typeId="srcBanks"/>
<channel id="mmscreen" typeId="srcMMScreen"/>
<channel id="mmtitle" typeId="srcMMTitle"/>
<channel id="mmmenu" typeId="srcMMMenu"/>
<channel id="mmattr" typeId="srcMMAttr"/>
<channel id="mmmenubuttonoktext" typeId="srcMMMenuButtonOkText"/>
<channel id="mmmenubuttonbacktext" typeId="srcMMMenuButtonBackText"/>
<channel id="mminfotext" typeId="srcMMInfoText"/>
<channel id="mmhelptext" typeId="srcMMHelpText"/>
<channel id="mmtextfield" typeId="srcMMTextField"/>
</channels>
<config-description>
<parameter name="source" type="integer" min="1" max="12" required="true">
<label>Source ID</label>
<description>The source identifier</description>
</parameter>
</config-description>
</thing-type>
<channel-type id="sysLang">
<item-type>String</item-type>
<label>Language</label>
<description>System Language</description>
<state>
<options>
<option value="ENGLISH">English</option>
<option value="CHINESE">Chinese</option>
<option value="RUSSIAN">Russian</option>
</options>
</state>
</channel-type>
<channel-type id="sysAllOn">
<item-type>Switch</item-type>
<label>All Zones</label>
<description>Toggles All Zones</description>
</channel-type>
<channel-type id="sysControllers" advanced="true">
<item-type>String</item-type>
<label>Controllers</label>
<description>JSON Array containing the valid controllers ([{id: 1, name: 'xxx'}, ...])</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="sysSources" advanced="true">
<item-type>String</item-type>
<label>Sources</label>
<description>JSON Array containing the sources ([{id: 1, name: 'xxx'}, ...])</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="ctlZones" advanced="true">
<item-type>String</item-type>
<label>Zones</label>
<description>JSON Array containing the zones ([{id: 1, name: 'xxx'}, ...])</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="zoneName">
<item-type>String</item-type>
<label>Zone Name</label>
<description>The name of the zone</description>
</channel-type>
<channel-type id="zoneSource">
<item-type>Number</item-type>
<label>Source</label>
<description>Physical Source Number</description>
<state min="1" max="12"/>
</channel-type>
<channel-type id="zoneBass" advanced="true">
<item-type>Number</item-type>
<label>Bass</label>
<description>Bass Setting</description>
<state min="-10" max="10"/>
</channel-type>
<channel-type id="zoneTreble" advanced="true">
<item-type>Number</item-type>
<label>Treble</label>
<description>Treble Setting</description>
<state min="-10" max="10"/>
</channel-type>
<channel-type id="zoneBalance" advanced="true">
<item-type>Number</item-type>
<label>Balance</label>
<description>Balance (-10 full left, 10 full right)</description>
<state min="-10" max="10"/>
</channel-type>
<channel-type id="zoneLoudness" advanced="true">
<item-type>Switch</item-type>
<label>Loudness</label>
<description>Loudness</description>
</channel-type>
<channel-type id="zoneTurnOnVolume" advanced="true">
<item-type>Dimmer</item-type>
<label>Turn On Volume</label>
<description>The volume the zone will default to when turned on</description>
</channel-type>
<channel-type id="zoneDoNotDisturb" advanced="true">
<item-type>String</item-type>
<label>Do Not Disturb</label>
<description>Do Not Disturb</description>
</channel-type>
<channel-type id="zonePartyMode" advanced="true">
<item-type>String</item-type>
<label>Party Mode</label>
<description>Party Mode</description>
</channel-type>
<channel-type id="zoneStatus">
<item-type>Switch</item-type>
<label>Status</label>
<description>Whether the zone is ON or OFF</description>
</channel-type>
<channel-type id="zoneVolume">
<item-type>Dimmer</item-type>
<label>Volume</label>
<description>Volume level of zone</description>
</channel-type>
<channel-type id="zoneMute">
<item-type>Switch</item-type>
<label>Mute</label>
<description>Whether the zone is muted</description>
</channel-type>
<channel-type id="zonePage" advanced="true">
<item-type>Switch</item-type>
<label>Page</label>
<description>Whether the zone is paging</description>
</channel-type>
<channel-type id="zoneSharedSource" advanced="true">
<item-type>Switch</item-type>
<label>Shared Source</label>
<description>Whether the zone is sharing it's source</description>
</channel-type>
<channel-type id="zoneSleepTimeRemaining" advanced="true">
<item-type>Number</item-type>
<label>Sleep Time Remaining</label>
<description>Sleep Time (in seconds) remaining</description>
<state min="0" max="60" step="5" readOnly="true"/>
</channel-type>
<channel-type id="zoneLastError" advanced="true">
<item-type>String</item-type>
<label>Last Error</label>
<description>last Error encountered in the zone</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="zoneEnabled" advanced="true">
<item-type>Switch</item-type>
<label>Enabled</label>
<description>Whether the zone is enabled or not</description>
</channel-type>
<channel-type id="zoneRepeat" advanced="true">
<item-type>Switch</item-type>
<label>Source Repeat</label>
<description>Toggle the zone's repeat mode</description>
</channel-type>
<channel-type id="zoneShuffle" advanced="true">
<item-type>Switch</item-type>
<label>Source Shuffle</label>
<description>Toggle the zone's shuffle mode</description>
</channel-type>
<channel-type id="zoneRating" advanced="true">
<item-type>Switch</item-type>
<label>Rating</label>
<description>How to rate the current song (like/dislike)</description>
</channel-type>
<channel-type id="zoneKeyPress" advanced="true">
<item-type>String</item-type>
<label>KeyPress Event</label>
<description>Send a KeyPress event to the zone</description>
</channel-type>
<channel-type id="zoneKeyRelease" advanced="true">
<item-type>String</item-type>
<label>KeyRelease Event</label>
<description>Send a KeyRelease event to the zone</description>
</channel-type>
<channel-type id="zoneKeyHold" advanced="true">
<item-type>String</item-type>
<label>KeyHold Event</label>
<description>Send a KeyHold event to the zone</description>
</channel-type>
<channel-type id="zoneKeyCode" advanced="true">
<item-type>String</item-type>
<label>KeyCode Event</label>
<description>Send a KeyCode event to the zone</description>
</channel-type>
<channel-type id="zoneEvent" advanced="true">
<item-type>String</item-type>
<label>Generic Event</label>
<description>Send a generic event to the zone</description>
</channel-type>
<channel-type id="zoneSysFavorites" advanced="true">
<item-type>String</item-type>
<label>System Favorites</label>
<description>JSON Array containing the system favorites ([{id: 1, valid: true/false, name: 'xxx'}, ...])</description>
</channel-type>
<channel-type id="zoneFavorites" advanced="true">
<item-type>String</item-type>
<label>Zone Favorites</label>
<description>JSON Array containing the zone favorites ([{id: 1, valid: true/false, name: 'xxx'}, ...])</description>
</channel-type>
<channel-type id="zonePresets" advanced="true">
<item-type>String</item-type>
<label>Zone Presets</label>
<description>JSON Array containing the zone presets ([{id: 1, valid: true/false, name: 'xxx'}, ...])</description>
</channel-type>
<channel-type id="zoneMMInit" advanced="true">
<item-type>Switch</item-type>
<label>MM Initialize</label>
<description>Send MM back to home screen</description>
</channel-type>
<channel-type id="zoneMMContextMenu" advanced="true">
<item-type>Switch</item-type>
<label>MM Context Menu</label>
<description>Request a source context menu</description>
</channel-type>
<channel-type id="srcName">
<item-type>String</item-type>
<label>Name</label>
<description>Source Name</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcType">
<item-type>String</item-type>
<label>Type</label>
<description>Source Type</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcChannel">
<item-type>String</item-type>
<label>Channel</label>
<description>Source's Channel</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcChannelName">
<item-type>String</item-type>
<label>ChannelName</label>
<description>Source's Channel Name</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcComposerName" advanced="true">
<item-type>String</item-type>
<label>Composer Name</label>
<description>Current song's composer name</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcGenre" advanced="true">
<item-type>String</item-type>
<label>Genre</label>
<description>Current song's genre</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcArtistName">
<item-type>String</item-type>
<label>Artist Name</label>
<description>Current song's artist name</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcAlbumName">
<item-type>String</item-type>
<label>Album Name</label>
<description>Current song's album</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcCoverArtUrl" advanced="true">
<item-type>String</item-type>
<label>Cover Art URL</label>
<description>Current song's covert art url</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcPlayListName" advanced="true">
<item-type>String</item-type>
<label>Play List Name</label>
<description>Name of the current playlist</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcSongName">
<item-type>String</item-type>
<label>Song Name</label>
<description>Name of the current song</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcRating" advanced="true">
<item-type>String</item-type>
<label>Song Rating</label>
<description>Rating of the current song</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMode">
<item-type>String</item-type>
<label>Mode</label>
<description>Provider Mode or Streaming Service</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcShuffleMode" advanced="true">
<item-type>String</item-type>
<label>Shuffle Mode</label>
<description>Source Shuffle Mode</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcRepeatMode" advanced="true">
<item-type>String</item-type>
<label>Repeat Mode</label>
<description>Source Repeat Mode</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcProgramServiceName" advanced="true">
<item-type>String</item-type>
<label>Program Service Name</label>
<description>Program Service Name (PSN)</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcRadioText">
<item-type>String</item-type>
<label>Radio Text</label>
<description>Radio Text</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcRadioText2" advanced="true">
<item-type>String</item-type>
<label>Radio Text 2</label>
<description>Radio Text line 2</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcRadioText3" advanced="true">
<item-type>String</item-type>
<label>Radio Text 3</label>
<description>Radio Text line 3</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcRadioText4" advanced="true">
<item-type>String</item-type>
<label>Radio Text 4</label>
<description>Radio Text line 4</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcVolume" advanced="true">
<item-type>String</item-type>
<label>Volume</label>
<description>The volume level of the source</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcBanks" advanced="true">
<item-type>String</item-type>
<label>Banks</label>
<description>JSON Array containing the banks ([{id: 1, name: 'xxx', presets: [{id:1 ,valid:true/false, name='xxx'},
...], ...])</description>
</channel-type>
<channel-type id="srcMMScreen" advanced="true">
<item-type>String</item-type>
<label>MM Screen</label>
<description>The MM Screen ID</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMMTitle" advanced="true">
<item-type>String</item-type>
<label>MM Screen Title</label>
<description>The MM Screen Title</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMMMenu" advanced="true">
<item-type>String</item-type>
<label>MM Menus</label>
<description>The MM Menu Item JSON</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMMAttr" advanced="true">
<item-type>String</item-type>
<label>MM Menus Attribute</label>
<description>The MM Menu Info Attributes</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMMMenuButtonOkText" advanced="true">
<item-type>String</item-type>
<label>MM OK Button Text</label>
<description>The MM OK Button Text</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMMMenuButtonBackText" advanced="true">
<item-type>String</item-type>
<label>MM Back Button Text</label>
<description>The MM Back Button Text</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMMInfoText" advanced="true">
<item-type>String</item-type>
<label>MM Info Text</label>
<description>The MM Info Text</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMMHelpText" advanced="true">
<item-type>String</item-type>
<label>MM Help Text</label>
<description>The MM Help Text (label for form)</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="srcMMTextField" advanced="true">
<item-type>String</item-type>
<label>MM Text Field</label>
<description>The MM Text Field</description>
<state readOnly="true"/>
</channel-type>
</thing:thing-descriptions>