[anthem] Initial contribution of binding for Anthem AV preamp/processors (#14311)

* Initial contribution

Signed-off-by: Mark Hilbush <mark@hilbush.com>
This commit is contained in:
Mark Hilbush 2023-03-26 15:32:08 -04:00 committed by GitHub
parent f98f820325
commit 749cf585ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1301 additions and 0 deletions

View File

@ -25,6 +25,7 @@
/bundles/org.openhab.binding.amplipi/ @kaikreuzer
/bundles/org.openhab.binding.androiddebugbridge/ @GiviMAD
/bundles/org.openhab.binding.anel/ @paphko
/bundles/org.openhab.binding.anthem/ @mhilbush
/bundles/org.openhab.binding.astro/ @gerrieg
/bundles/org.openhab.binding.atlona/ @tmrobert8 @mlobstein
/bundles/org.openhab.binding.autelis/ @digitaldan

View File

@ -116,6 +116,11 @@
<artifactId>org.openhab.binding.anel</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.anthem</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.astro</artifactId>

View File

@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons

View File

@ -0,0 +1,77 @@
# Anthem Binding
The binding allows control of Anthem AV processors over an IP connection to the processor.
## Supported Things
The following thing type is supported:
| Thing | ID | Discovery | Description |
|----------|----------|-----------|-------------|
| Anthem | anthem | Manual | Represents a Anthem AV processor |
Tested models include the AVM-60 11.2-channel preamp/processor.
## Thing Configuration
The following configuration parameters are available on the Anthem thing:
| Parameter | Parameter ID | Required/Optional | Description |
|---------------------|---------------------------|-------------------|-------------|
| Host | host | Required | IP address or host name of the Anthem AV processor |
| Port | port | Optional | Port number used by the Anthem |
| Reconnect Interval | reconnectIntervalMinutes | Optional | The time to wait between reconnection attempts (in minutes) |
| Command Delay | commandDelayMsec | Optional | The delay between commands sent to the processor (in milliseconds) |
## Channels
The Anthem AV processor supports the following channels (some zones/channels are model specific):
| Channel | Type | Description |
|-------------------------|---------|--------------|
| *Main Zone* | | |
| 1#power | Switch | Power the zone on or off |
| 1#volume | Dimmer | Increase or decrease the volume level |
| 1#volumeDB | Number | The actual volume setting |
| 1#mute | Switch | Mute the volume |
| 1#activeInput | Number | The currently active input source |
| 1#activeInputShortName | String | Short friendly name of the active input |
| 1#activeInputLongName | String | Long friendly name of the active input |
| *Zone 2* | | |
| 2#power | Switch | Power the zone on or off |
| 2#volume | Dimmer | Increase or decrease the volume level |
| 2#volumeDB | Number | The actual volume setting |
| 2#mute | Switch | Mute the volume |
| 2#activeInput | Number | The currently active input source |
| 2#activeInputShortName | String | Short friendly name of the active input |
| 2#activeInputLongName | String | Long friendly name of the active input |
## Full Example
### Things
```
Thing anthem:anthem:mediaroom "Anthem AVM 60" [ host="192.168.1.100" ]
```
### Items
```
Switch Anthem_Z1_Power "Zone 1 Power [%s]" { channel="anthem:anthem:mediaroom:1#power" }
Dimmer Anthem_Z1_Volume "Zone 1 Volume [%s]" { channel="anthem:anthem:mediaroom:1#volume" }
Number Anthem_Z1_Volume_DB "Zone 1 Volume dB [%.0f]" { channel="anthem:anthem:mediaroom:1#volumeDB" }
Switch Anthem_Z1_Mute "Zone 1 Mute [%s]" { channel="anthem:anthem:mediaroom:1#mute" }
Number Anthem_Z1_ActiveInput "Zone 1 Active Input [%.0f]" { channel="anthem:anthem:mediaroom:1#activeInput" }
String Anthem_Z1_ActiveInputShortName "Zone 1 Active Input Short Name [%s]" { channel="anthem:anthem:mediaroom:1#activeInputShortName" }
String Anthem_Z1_ActiveInputLongName "Zone 1 Active Input Long Name [%s]" { channel="anthem:anthem:mediaroom:1#activeInputLongName" }
Switch Anthem_Z2_Power "Zone 2 Power [%s]" { channel="anthem:anthem:mediaroom:1#power" }
Dimmer Anthem_Z2_Volume "Zone 2 Volume [%s]" { channel="anthem:anthem:mediaroom:1#volume" }
Number Anthem_Z2_Volume_DB "Zone 2 Volume dB [%.0f]" { channel="anthem:anthem:mediaroom:1#volumeDB" }
Switch Anthem_Z2_Mute "Zone 2 Mute [%s]" { channel="anthem:anthem:mediaroom:1#mute" }
Number Anthem_Z2_ActiveInput "Zone 2 Active Input [%.0f]" { channel="anthem:anthem:mediaroom:1#activeInput" }
String Anthem_Z2_ActiveInputShortName "Zone 2 Active Input Short Name [%s]" { channel="anthem:anthem:mediaroom:1#activeInputShortName" }
String Anthem_Z2_ActiveInputLongName "Zone 2 Active Input Long Name [%s]" { channel="anthem:anthem:mediaroom:1#activeInputLongName" }
```

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.anthem</artifactId>
<name>openHAB Add-ons :: Bundles :: Anthem Binding</name>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.anthem-${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-anthem" description="Anthem Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.anthem/${project.version}</bundle>
</feature>
</features>

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2010-2023 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.anthem.internal;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link AnthemBindingConstants} class defines common constants, which are
* used across the entire binding.
*
* @author Mark Hilbush - Initial contribution
*/
@NonNullByDefault
public class AnthemBindingConstants {
public static final String BINDING_ID = "anthem";
public static final ThingTypeUID THING_TYPE_ANTHEM = new ThingTypeUID(BINDING_ID, "anthem");
// List of all Thing Type UIDs
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ANTHEM);
// Channel Ids
public static final String CHANNEL_POWER = "power";
public static final String CHANNEL_VOLUME = "volume";
public static final String CHANNEL_VOLUME_DB = "volumeDB";
public static final String CHANNEL_MUTE = "mute";
public static final String CHANNEL_ACTIVE_INPUT = "activeInput";
public static final String CHANNEL_ACTIVE_INPUT_SHORT_NAME = "activeInputShortName";
public static final String CHANNEL_ACTIVE_INPUT_LONG_NAME = "activeInputLongName";
// Connection-related configuration parameters
public static final int DEFAULT_PORT = 14999;
public static final int DEFAULT_RECONNECT_INTERVAL_MINUTES = 2;
public static final int DEFAULT_COMMAND_DELAY_MSEC = 100;
public static final char COMMAND_TERMINATION_CHAR = ';';
}

View File

@ -0,0 +1,43 @@
/**
* Copyright (c) 2010-2023 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.anthem.internal;
import static org.openhab.binding.anthem.internal.AnthemBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link AnthemConfiguration} is responsible for storing the Anthem thing configuration.
*
* @author Mark Hilbush - Initial contribution
*/
@NonNullByDefault
public class AnthemConfiguration {
public String host = "";
public int port = DEFAULT_PORT;
public int reconnectIntervalMinutes = DEFAULT_RECONNECT_INTERVAL_MINUTES;
public int commandDelayMsec = DEFAULT_COMMAND_DELAY_MSEC;
public boolean isValid() {
return !host.isBlank();
}
@Override
public String toString() {
return "AnthemConfiguration{ host=" + host + ", port=" + port + ", reconectIntervalMinutes="
+ reconnectIntervalMinutes + ", commandDelayMsec=" + commandDelayMsec + " }";
}
}

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2010-2023 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.anthem.internal;
import static org.openhab.binding.anthem.internal.AnthemBindingConstants.SUPPORTED_THING_TYPES_UIDS;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.anthem.internal.handler.AnthemHandler;
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.osgi.service.component.annotations.ConfigurationPolicy;
/**
* The {@link AnthemHandlerFactory} is responsible for creating Anthem thing handlers.
*
* @author Mark Hilbush - Initial contribution
*/
@NonNullByDefault
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.anthem", configurationPolicy = ConfigurationPolicy.OPTIONAL)
public class AnthemHandlerFactory extends BaseThingHandlerFactory {
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
return new AnthemHandler(thing);
}
return null;
}
}

View File

@ -0,0 +1,127 @@
/**
* Copyright (c) 2010-2023 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.anthem.internal.handler;
import static org.openhab.binding.anthem.internal.AnthemBindingConstants.COMMAND_TERMINATION_CHAR;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link AnthemCommend} is responsible for creating commands to be sent to the
* Anthem processor.
*
* @author Mark Hilbush - Initial contribution
*/
@NonNullByDefault
public class AnthemCommand {
private static final String COMMAND_TERMINATOR = String.valueOf(COMMAND_TERMINATION_CHAR);
private String command = "";
public AnthemCommand(String command) {
this.command = command;
}
public static AnthemCommand powerOn(Zone zone) {
return new AnthemCommand(String.format("Z%sPOW1", zone.getValue()));
}
public static AnthemCommand powerOff(Zone zone) {
return new AnthemCommand(String.format("Z%sPOW0", zone.getValue()));
}
public static AnthemCommand volumeUp(Zone zone, int amount) {
return new AnthemCommand(String.format("Z%sVUP%02d", zone.getValue(), amount));
}
public static AnthemCommand volumeDown(Zone zone, int amount) {
return new AnthemCommand(String.format("Z%sVDN%02d", zone.getValue(), amount));
}
public static AnthemCommand volume(Zone zone, int level) {
return new AnthemCommand(String.format("Z%sVOL%02d", zone.getValue(), level));
}
public static AnthemCommand muteOn(Zone zone) {
return new AnthemCommand(String.format("Z%sMUT1", zone.getValue()));
}
public static AnthemCommand muteOff(Zone zone) {
return new AnthemCommand(String.format("Z%sMUT0", zone.getValue()));
}
public static AnthemCommand activeInput(Zone zone, int input) {
return new AnthemCommand(String.format("Z%sINP%02d", zone.getValue(), input));
}
public static AnthemCommand queryPower(Zone zone) {
return new AnthemCommand(String.format("Z%sPOW?", zone.getValue()));
}
public static AnthemCommand queryVolume(Zone zone) {
return new AnthemCommand(String.format("Z%sVOL?", zone.getValue()));
}
public static AnthemCommand queryMute(Zone zone) {
return new AnthemCommand(String.format("Z%sMUT?", zone.getValue()));
}
public static AnthemCommand queryActiveInput(Zone zone) {
return new AnthemCommand(String.format("Z%sINP?", zone.getValue()));
}
public static AnthemCommand queryNumAvailableInputs() {
return new AnthemCommand(String.format("ICN?"));
}
public static AnthemCommand queryInputShortName(int input) {
return new AnthemCommand(String.format("ISN%02d?", input));
}
public static AnthemCommand queryInputLongName(int input) {
return new AnthemCommand(String.format("ILN%02d?", input));
}
public static AnthemCommand queryModel() {
return new AnthemCommand("IDM?");
}
public static AnthemCommand queryRegion() {
return new AnthemCommand("IDR?");
}
public static AnthemCommand querySoftwareVersion() {
return new AnthemCommand("IDS?");
}
public static AnthemCommand querySoftwareBuildDate() {
return new AnthemCommand("IDB?");
}
public static AnthemCommand queryHardwareVersion() {
return new AnthemCommand("IDH?");
}
public static AnthemCommand queryMacAddress() {
return new AnthemCommand("IDN?");
}
public String getCommand() {
return command + COMMAND_TERMINATOR;
}
@Override
public String toString() {
return getCommand();
}
}

View File

@ -0,0 +1,263 @@
/**
* Copyright (c) 2010-2023 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.anthem.internal.handler;
import static org.openhab.binding.anthem.internal.AnthemBindingConstants.*;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.StringType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AnthemCommandParser} is responsible for parsing and handling
* commands received from the Anthem processor.
*
* @author Mark Hilbush - Initial contribution
*/
@NonNullByDefault
public class AnthemCommandParser {
private static final Pattern NUM_AVAILABLE_INPUTS_PATTERN = Pattern.compile("ICN([0-9])");
private static final Pattern INPUT_SHORT_NAME_PATTERN = Pattern.compile("ISN([0-9][0-9])(\\p{ASCII}*)");
private static final Pattern INPUT_LONG_NAME_PATTERN = Pattern.compile("ILN([0-9][0-9])(\\p{ASCII}*)");
private static final Pattern POWER_PATTERN = Pattern.compile("Z([0-9])POW([01])");
private static final Pattern VOLUME_PATTERN = Pattern.compile("Z([0-9])VOL(-?[0-9]*)");
private static final Pattern MUTE_PATTERN = Pattern.compile("Z([0-9])MUT([01])");
private static final Pattern ACTIVE_INPUT_PATTERN = Pattern.compile("Z([0-9])INP([1-9])");
private Logger logger = LoggerFactory.getLogger(AnthemCommandParser.class);
private AnthemHandler handler;
private Map<Integer, String> inputShortNamesMap = new HashMap<>();
private Map<Integer, String> inputLongNamesMap = new HashMap<>();
private int numAvailableInputs;
public AnthemCommandParser(AnthemHandler anthemHandler) {
this.handler = anthemHandler;
}
public int getNumAvailableInputs() {
return numAvailableInputs;
}
public void parseMessage(String command) {
if (!isValidCommand(command)) {
return;
}
// Strip off the termination char and any whitespace
String cmd = command.substring(0, command.indexOf(COMMAND_TERMINATION_CHAR)).trim();
// Zone command
if (cmd.startsWith("Z")) {
parseZoneCommand(cmd);
}
// Information command
else if (cmd.startsWith("ID")) {
parseInformationCommand(cmd);
}
// Number of inputs
else if (cmd.startsWith("ICN")) {
parseNumberOfAvailableInputsCommand(cmd);
}
// Input short name
else if (cmd.startsWith("ISN")) {
parseInputShortNameCommand(cmd);
}
// Input long name
else if (cmd.startsWith("ILN")) {
parseInputLongNameCommand(cmd);
}
// Error response to command
else if (cmd.startsWith("!")) {
parseErrorCommand(cmd);
}
// Unknown/unhandled command
else {
logger.trace("Command parser doesn't know how to handle command: '{}'", cmd);
}
}
private boolean isValidCommand(String command) {
if (command.isEmpty() || command.isBlank() || command.length() < 4
|| command.indexOf(COMMAND_TERMINATION_CHAR) == -1) {
logger.trace("Parser received invalid command: '{}'", command);
return false;
}
return true;
}
private void parseZoneCommand(String command) {
// Power update
if (command.contains("POW")) {
parsePower(command);
}
// Volume update
else if (command.contains("VOL")) {
parseVolume(command);
}
// Mute update
else if (command.contains("MUT")) {
parseMute(command);
}
// Active input
else if (command.contains("INP")) {
parseActiveInput(command);
}
}
private void parseInformationCommand(String command) {
String value = command.substring(3, command.length());
switch (command.substring(2, 3)) {
case "M":
handler.setModel(value);
break;
case "R":
handler.setRegion(value);
break;
case "S":
handler.setSoftwareVersion(value);
break;
case "B":
handler.setSoftwareBuildDate(value);
break;
case "H":
handler.setHardwareVersion(value);
break;
case "N":
handler.setMacAddress(value);
break;
case "Q":
// Ignore
break;
default:
logger.debug("Unknown info type");
break;
}
}
private void parseNumberOfAvailableInputsCommand(String command) {
Matcher matcher = NUM_AVAILABLE_INPUTS_PATTERN.matcher(command);
if (matcher != null) {
try {
matcher.find();
String numAvailableInputsStr = matcher.group(1);
DecimalType numAvailableInputs = DecimalType.valueOf(numAvailableInputsStr);
handler.setNumAvailableInputs(numAvailableInputs.intValue());
this.numAvailableInputs = numAvailableInputs.intValue();
} catch (NumberFormatException | IndexOutOfBoundsException | IllegalStateException e) {
logger.debug("Parsing exception on command: {}", command, e);
}
}
}
private void parseInputShortNameCommand(String command) {
parseInputName(command, INPUT_SHORT_NAME_PATTERN.matcher(command), inputShortNamesMap);
}
private void parseInputLongNameCommand(String command) {
parseInputName(command, INPUT_LONG_NAME_PATTERN.matcher(command), inputLongNamesMap);
}
private void parseErrorCommand(String command) {
logger.info("Command was not processed successfully by the device: '{}'", command);
}
private void parseInputName(String command, @Nullable Matcher matcher, Map<Integer, String> map) {
if (matcher != null) {
try {
matcher.find();
int input = Integer.parseInt(matcher.group(1));
String inputName = matcher.group(2);
map.putIfAbsent(input, inputName);
} catch (NumberFormatException | IndexOutOfBoundsException | IllegalStateException e) {
logger.debug("Parsing exception on command: {}", command, e);
}
}
}
private void parsePower(String command) {
Matcher mmatcher = POWER_PATTERN.matcher(command);
if (mmatcher != null) {
try {
mmatcher.find();
String zone = mmatcher.group(1);
String power = mmatcher.group(2);
handler.updateChannelState(zone, CHANNEL_POWER, "1".equals(power) ? OnOffType.ON : OnOffType.OFF);
handler.checkPowerStatusChange(zone, power);
} catch (IndexOutOfBoundsException | IllegalStateException e) {
logger.debug("Parsing exception on command: {}", command, e);
}
}
}
private void parseVolume(String command) {
Matcher matcher = VOLUME_PATTERN.matcher(command);
if (matcher != null) {
try {
matcher.find();
String zone = matcher.group(1);
String volume = matcher.group(2);
handler.updateChannelState(zone, CHANNEL_VOLUME_DB, DecimalType.valueOf(volume));
} catch (NumberFormatException | IndexOutOfBoundsException | IllegalStateException e) {
logger.debug("Parsing exception on command: {}", command, e);
}
}
}
private void parseMute(String command) {
Matcher matcher = MUTE_PATTERN.matcher(command);
if (matcher != null) {
try {
matcher.find();
String zone = matcher.group(1);
String mute = matcher.group(2);
handler.updateChannelState(zone, CHANNEL_MUTE, "1".equals(mute) ? OnOffType.ON : OnOffType.OFF);
} catch (IndexOutOfBoundsException | IllegalStateException e) {
logger.debug("Parsing exception on command: {}", command, e);
}
}
}
private void parseActiveInput(String command) {
Matcher matcher = ACTIVE_INPUT_PATTERN.matcher(command);
if (matcher != null) {
try {
matcher.find();
String zone = matcher.group(1);
DecimalType activeInput = DecimalType.valueOf(matcher.group(2));
handler.updateChannelState(zone, CHANNEL_ACTIVE_INPUT, activeInput);
String name;
name = inputShortNamesMap.get(activeInput.intValue());
if (name != null) {
handler.updateChannelState(zone, CHANNEL_ACTIVE_INPUT_SHORT_NAME, new StringType(name));
}
name = inputShortNamesMap.get(activeInput.intValue());
if (name != null) {
handler.updateChannelState(zone, CHANNEL_ACTIVE_INPUT_LONG_NAME, new StringType(name));
}
} catch (NumberFormatException | IndexOutOfBoundsException | IllegalStateException e) {
logger.debug("Parsing exception on command: {}", command, e);
}
}
}
}

View File

@ -0,0 +1,437 @@
/**
* Copyright (c) 2010-2023 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.anthem.internal.handler;
import static org.openhab.binding.anthem.internal.AnthemBindingConstants.*;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.InterruptedIOException;
import java.io.OutputStreamWriter;
import java.net.Socket;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.anthem.internal.AnthemConfiguration;
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.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.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AnthemHandler} is responsible for handling commands, which are
* sent to one of the channels. It also manages the connection to the AV processor.
* The reader thread receives solicited and unsolicited commands from the processor.
* The sender thread is used to send commands to the processor.
*
* @author Mark Hilbush - Initial contribution
*/
@NonNullByDefault
public class AnthemHandler extends BaseThingHandler {
private Logger logger = LoggerFactory.getLogger(AnthemHandler.class);
private static final long POLLING_INTERVAL_SECONDS = 900L;
private static final long POLLING_DELAY_SECONDS = 10L;
private @Nullable Socket socket;
private @Nullable BufferedWriter writer;
private @Nullable BufferedReader reader;
private AnthemCommandParser messageParser;
private final BlockingQueue<AnthemCommand> sendQueue = new LinkedBlockingQueue<>();
private @Nullable Future<?> asyncInitializeTask;
private @Nullable ScheduledFuture<?> connectRetryJob;
private @Nullable ScheduledFuture<?> pollingJob;
private @Nullable Thread senderThread;
private @Nullable Thread readerThread;
private int reconnectIntervalMinutes;
private int commandDelayMsec;
private boolean zone1PreviousPowerState;
private boolean zone2PreviousPowerState;
public AnthemHandler(Thing thing) {
super(thing);
messageParser = new AnthemCommandParser(this);
}
@Override
public void initialize() {
AnthemConfiguration configuration = getConfig().as(AnthemConfiguration.class);
logger.debug("AnthemHandler: Configuration of thing {} is {}", thing.getUID().getId(), configuration);
if (!configuration.isValid()) {
logger.debug("AnthemHandler: Config of thing '{}' is invalid", thing.getUID().getId());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/thing-status-detail-invalidconfig");
return;
}
reconnectIntervalMinutes = configuration.reconnectIntervalMinutes;
commandDelayMsec = configuration.commandDelayMsec;
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "@text/thing-status-detail-connecting");
asyncInitializeTask = scheduler.submit(this::connect);
}
@Override
public void dispose() {
Future<?> localAsyncInitializeTask = this.asyncInitializeTask;
if (localAsyncInitializeTask != null) {
localAsyncInitializeTask.cancel(true);
this.asyncInitializeTask = null;
}
disconnect();
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.trace("Command {} received for channel {}", command, channelUID.getId().toString());
String groupId = channelUID.getGroupId();
if (groupId == null) {
return;
}
Zone zone = Zone.fromValue(groupId);
switch (channelUID.getIdWithoutGroup()) {
case CHANNEL_POWER:
if (command instanceof OnOffType) {
if (command == OnOffType.ON) {
// Power on the device
sendCommand(AnthemCommand.powerOn(zone));
} else if (command == OnOffType.OFF) {
sendCommand(AnthemCommand.powerOff(zone));
}
}
break;
case CHANNEL_VOLUME:
if (command instanceof OnOffType || command instanceof IncreaseDecreaseType) {
if (command == OnOffType.ON || command == IncreaseDecreaseType.INCREASE) {
sendCommand(AnthemCommand.volumeUp(zone, 1));
} else if (command == OnOffType.OFF || command == IncreaseDecreaseType.DECREASE) {
sendCommand(AnthemCommand.volumeDown(zone, 1));
}
}
break;
case CHANNEL_VOLUME_DB:
if (command instanceof DecimalType) {
sendCommand(AnthemCommand.volume(zone, ((DecimalType) command).intValue()));
}
break;
case CHANNEL_MUTE:
if (command instanceof OnOffType) {
if (command == OnOffType.ON) {
sendCommand(AnthemCommand.muteOn(zone));
} else if (command == OnOffType.OFF) {
sendCommand(AnthemCommand.muteOff(zone));
}
}
break;
case CHANNEL_ACTIVE_INPUT:
if (command instanceof DecimalType) {
sendCommand(AnthemCommand.activeInput(zone, ((DecimalType) command).intValue()));
}
break;
default:
logger.debug("Received command '{}' for unhandled channel '{}'", command, channelUID.getId());
break;
}
}
public void setModel(String model) {
updateProperty("Model", model);
}
public void setRegion(String region) {
updateProperty("Region", region);
}
public void setSoftwareVersion(String version) {
updateProperty("Software Version", version);
}
public void setSoftwareBuildDate(String date) {
updateProperty("Software Build Date", date);
}
public void setHardwareVersion(String version) {
updateProperty("Hardware Version", version);
}
public void setMacAddress(String mac) {
updateProperty("Mac Address", mac);
}
public void updateChannelState(String zone, String channelId, State state) {
updateState(zone + "#" + channelId, state);
}
public void checkPowerStatusChange(String zone, String power) {
// Zone 1
if (Zone.MAIN.equals(Zone.fromValue(zone))) {
boolean newZone1PowerState = "1".equals(power) ? true : false;
if (!zone1PreviousPowerState && newZone1PowerState) {
// Power turned on for main zone.
// This will cause the main zone channel states to be updated
scheduler.submit(() -> queryAdditionalInformation(Zone.MAIN));
}
zone1PreviousPowerState = newZone1PowerState;
}
// Zone 2
else if (Zone.ZONE2.equals(Zone.fromValue(zone))) {
boolean newZone2PowerState = "1".equals(power) ? true : false;
if (!zone2PreviousPowerState && newZone2PowerState) {
// Power turned on for zone 2.
// This will cause zone 2 channel states to be updated
scheduler.submit(() -> queryAdditionalInformation(Zone.ZONE2));
}
zone2PreviousPowerState = newZone2PowerState;
}
}
public void setNumAvailableInputs(int numInputs) {
// Request the names for all the inputs
for (int input = 1; input <= numInputs; input++) {
sendCommand(AnthemCommand.queryInputShortName(input));
sendCommand(AnthemCommand.queryInputLongName(input));
}
updateProperty("Number of Inputs", String.valueOf(numInputs));
}
private void queryAdditionalInformation(Zone zone) {
// Request information about the device
sendCommand(AnthemCommand.queryNumAvailableInputs());
sendCommand(AnthemCommand.queryModel());
sendCommand(AnthemCommand.queryRegion());
sendCommand(AnthemCommand.querySoftwareVersion());
sendCommand(AnthemCommand.querySoftwareBuildDate());
sendCommand(AnthemCommand.queryHardwareVersion());
sendCommand(AnthemCommand.queryMacAddress());
sendCommand(AnthemCommand.queryVolume(zone));
sendCommand(AnthemCommand.queryMute(zone));
// Give some time for the input names to populate before requesting the active input
scheduler.schedule(() -> queryActiveInput(zone), 5L, TimeUnit.SECONDS);
}
private void queryActiveInput(Zone zone) {
sendCommand(AnthemCommand.queryActiveInput(zone));
}
private void sendCommand(AnthemCommand command) {
logger.debug("Adding command to queue: {}", command);
sendQueue.add(command);
}
private synchronized void connect() {
try {
AnthemConfiguration configuration = getConfig().as(AnthemConfiguration.class);
logger.debug("Opening connection to Anthem host {} on port {}", configuration.host, configuration.port);
Socket socket = new Socket(configuration.host, configuration.port);
writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.ISO_8859_1));
reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.ISO_8859_1));
this.socket = socket;
} catch (UnknownHostException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/thing-status-detail-unknownhost");
return;
} catch (IllegalArgumentException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/thing-status-detail-invalidport");
return;
} catch (InterruptedIOException e) {
logger.debug("Interrupted while establishing Anthem connection");
Thread.currentThread().interrupt();
return;
} catch (IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/thing-status-detail-openerror");
logger.debug("Error opening Anthem connection: {}", e.getMessage());
disconnect();
scheduleConnectRetry(reconnectIntervalMinutes);
return;
}
Thread localReaderThread = new Thread(this::readerThreadJob, "Anthem reader");
localReaderThread.setDaemon(true);
localReaderThread.start();
this.readerThread = localReaderThread;
Thread localSenderThread = new Thread(this::senderThreadJob, "Anthem sender");
localSenderThread.setDaemon(true);
localSenderThread.start();
this.senderThread = localSenderThread;
updateStatus(ThingStatus.ONLINE);
ScheduledFuture<?> localPollingJob = this.pollingJob;
if (localPollingJob == null) {
this.pollingJob = scheduler.scheduleWithFixedDelay(this::poll, POLLING_DELAY_SECONDS,
POLLING_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
}
private void poll() {
logger.debug("Polling...");
sendCommand(AnthemCommand.queryPower(Zone.MAIN));
sendCommand(AnthemCommand.queryPower(Zone.ZONE2));
}
private void scheduleConnectRetry(long waitMinutes) {
logger.debug("Scheduling connection retry in {} minutes", waitMinutes);
connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
}
private synchronized void disconnect() {
logger.debug("Disconnecting from Anthem");
ScheduledFuture<?> localPollingJob = this.pollingJob;
if (localPollingJob != null) {
localPollingJob.cancel(true);
this.pollingJob = null;
}
ScheduledFuture<?> localConnectRetryJob = this.connectRetryJob;
if (localConnectRetryJob != null) {
localConnectRetryJob.cancel(true);
this.connectRetryJob = null;
}
Thread localSenderThread = this.senderThread;
if (localSenderThread != null && localSenderThread.isAlive()) {
localSenderThread.interrupt();
}
Thread localReaderThread = this.readerThread;
if (localReaderThread != null && localReaderThread.isAlive()) {
localReaderThread.interrupt();
}
Socket localSocket = this.socket;
if (localSocket != null) {
try {
localSocket.close();
} catch (IOException e) {
logger.debug("Error closing socket: {}", e.getMessage());
}
this.socket = null;
}
BufferedReader localReader = this.reader;
if (localReader != null) {
try {
localReader.close();
} catch (IOException e) {
logger.debug("Error closing reader: {}", e.getMessage());
}
this.reader = null;
}
BufferedWriter localWriter = this.writer;
if (localWriter != null) {
try {
localWriter.close();
} catch (IOException e) {
logger.debug("Error closing writer: {}", e.getMessage());
}
this.writer = null;
}
}
private synchronized void reconnect() {
logger.debug("Attempting to reconnect to the Anthem");
disconnect();
connect();
}
private void senderThreadJob() {
logger.debug("Sender thread started");
try {
while (!Thread.currentThread().isInterrupted() && writer != null) {
AnthemCommand command = sendQueue.take();
logger.debug("Sender thread writing command: {}", command);
try {
BufferedWriter localWriter = this.writer;
if (localWriter != null) {
localWriter.write(command.toString());
localWriter.flush();
}
} catch (InterruptedIOException e) {
logger.debug("Interrupted while sending command");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/thing-status-detail-interrupted");
break;
} catch (IOException e) {
logger.debug("Communication error, will try to reconnect. Error: {}", e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
// Requeue the command and try to reconnect
sendQueue.add(command);
reconnect();
break;
}
// Introduce delay to throttle the send rate
if (commandDelayMsec > 0) {
Thread.sleep(commandDelayMsec);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
logger.debug("Sender thread exiting");
}
}
private void readerThreadJob() {
logger.debug("Reader thread started");
StringBuffer sbReader = new StringBuffer();
try {
char c;
String command;
BufferedReader localReader = this.reader;
while (!Thread.interrupted() && localReader != null) {
c = (char) localReader.read();
sbReader.append(c);
if (c == COMMAND_TERMINATION_CHAR) {
command = sbReader.toString();
logger.debug("Reader thread sending command to parser: {}", command);
messageParser.parseMessage(command);
sbReader.setLength(0);
}
}
} catch (InterruptedIOException e) {
logger.debug("Interrupted while reading");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/thing-status-detail-interrupted");
} catch (IOException e) {
logger.debug("I/O error while reading from socket: {}", e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/thing-status-detail-ioexception");
} finally {
logger.debug("Reader thread exiting");
}
}
}

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2010-2023 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.anthem.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link Zone} defines the zones supported by the Anthem processor.
*
* @author Mark Hilbush - Initial contribution
*/
@NonNullByDefault
public enum Zone {
MAIN("1"),
ZONE2("2");
private final String value;
Zone(String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
public static Zone fromValue(String value) {
for (Zone m : Zone.values()) {
if (m.getValue().equals(value)) {
return m;
}
}
throw new IllegalArgumentException("Invalid or null zone: " + value);
}
@Override
public String toString() {
return this.value;
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="anthem" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>Anthem Binding</name>
<description>This is the binding for Anthem AV preamp/processors</description>
<connection>local</connection>
</addon:addon>

View File

@ -0,0 +1,50 @@
# add-on
addon.anthem.name = Anthem Binding
addon.anthem.description = This is the binding for Anthem AV preamp/processors
# thing types
thing-type.anthem.anthem.label = Anthem
thing-type.anthem.anthem.description = Thing for Anthem AV processor
thing-type.anthem.anthem.group.1.label = Main Zone
thing-type.anthem.anthem.group.1.description = Controls zone 1 (the main zone) of the processor
thing-type.anthem.anthem.group.2.label = Zone 2
thing-type.anthem.anthem.group.2.description = Controls zone 2 of the processor
# thing types config
thing-type.config.anthem.anthem.commandDelayMsec.label = Command Delay
thing-type.config.anthem.anthem.commandDelayMsec.description = The delay between commands sent to the processor (in milliseconds)
thing-type.config.anthem.anthem.host.label = Network Address
thing-type.config.anthem.anthem.host.description = Host name or IP address of the Anthem AV processor
thing-type.config.anthem.anthem.port.label = Network Port
thing-type.config.anthem.anthem.port.description = Network port number of the Anthem AV processor
thing-type.config.anthem.anthem.reconnectIntervalMinutes.label = Reconnect Interval
thing-type.config.anthem.anthem.reconnectIntervalMinutes.description = The time to wait between reconnection attempts (in minutes)
# channel group types
channel-group-type.anthem.zone.label = Zone Control
channel-group-type.anthem.zone.description = Channels for a zone of this processor
# channel types
channel-type.anthem.activeInput.label = Active Input
channel-type.anthem.activeInput.description = Selects the active input source
channel-type.anthem.activeInputLongName.label = Active Input Long Name
channel-type.anthem.activeInputLongName.description = Long friendly name of the active input source
channel-type.anthem.activeInputShortName.label = Active Input Short Name
channel-type.anthem.activeInputShortName.description = Short friendly name of the active input source
channel-type.anthem.volumeDB.label = Volume dB
channel-type.anthem.volumeDB.description = Set the volume level dB between -90 and 0
# thing status detail messages
thing-status-detail-connecting = Connecting
thing-status-detail-unknownhost = Unknown host
thing-status-detail-invalidport = Invalid port number
thing-status-detail-openerror = Error opening Anthem connection. Check log
thing-status-detail-interrupted = Interrupted
thing-status-detail-ioerror = I/O Error
thing-status-detail-invalidconfig = Invalid Anthem thing configuration

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="anthem"
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">
<thing-type id="anthem">
<label>Anthem</label>
<description>Thing for Anthem AV processor</description>
<channel-groups>
<channel-group id="1" typeId="zone">
<label>Main Zone</label>
<description>Controls zone 1 (the main zone) of the processor</description>
</channel-group>
<channel-group id="2" typeId="zone">
<label>Zone 2</label>
<description>Controls zone 2 of the processor</description>
</channel-group>
</channel-groups>
<config-description>
<parameter name="host" type="text" required="true">
<label>Network Address</label>
<description>Host name or IP address of the Anthem AV processor</description>
<context>network-address</context>
</parameter>
<parameter name="port" type="integer">
<label>Network Port</label>
<description>Network port number of the Anthem AV processor</description>
<default>14999</default>
<advanced>true</advanced>
</parameter>
<parameter name="reconnectIntervalMinutes" type="integer">
<label>Reconnect Interval</label>
<description>The time to wait between reconnection attempts (in minutes)</description>
<default>2</default>
<advanced>true</advanced>
</parameter>
<parameter name="commandDelayMsec" type="integer">
<label>Command Delay</label>
<description>The delay between commands sent to the processor (in milliseconds)</description>
<default>100</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<channel-group-type id="zone">
<label>Zone Control</label>
<description>Channels for a zone of this processor</description>
<channels>
<channel id="power" typeId="system.power"/>
<channel id="volume" typeId="system.volume"/>
<channel id="volumeDB" typeId="volumeDB"/>
<channel id="mute" typeId="system.mute"/>
<channel id="activeInput" typeId="activeInput"/>
<channel id="activeInputShortName" typeId="activeInputShortName"/>
<channel id="activeInputLongName" typeId="activeInputLongName"/>
</channels>
</channel-group-type>
<!-- Channel types -->
<channel-type id="volumeDB" advanced="true">
<item-type>Number</item-type>
<label>Volume dB</label>
<description>Set the volume level dB between -90 and 0</description>
<category>SoundVolume</category>
<state min="-90" max="0" step="1" pattern="%.0f dB"/>
</channel-type>
<channel-type id="activeInput">
<item-type>Number</item-type>
<label>Active Input</label>
<description>Selects the active input source</description>
</channel-type>
<channel-type id="activeInputShortName">
<item-type>String</item-type>
<label>Active Input Short Name</label>
<description>Short friendly name of the active input source</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="activeInputLongName" advanced="true">
<item-type>String</item-type>
<label>Active Input Long Name</label>
<description>Long friendly name of the active input source</description>
<state readOnly="true"></state>
</channel-type>
</thing:thing-descriptions>

View File

@ -58,6 +58,7 @@
<module>org.openhab.binding.amplipi</module>
<module>org.openhab.binding.androiddebugbridge</module>
<module>org.openhab.binding.anel</module>
<module>org.openhab.binding.anthem</module>
<module>org.openhab.binding.astro</module>
<module>org.openhab.binding.atlona</module>
<module>org.openhab.binding.autelis</module>