added migrated 2.x add-ons
Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user