]> git.basschouten.com Git - openhab-addons.git/commitdiff
[anthem] Initial contribution of binding for Anthem AV preamp/processors (#14311)
authorMark Hilbush <mark@hilbush.com>
Sun, 26 Mar 2023 19:32:08 +0000 (15:32 -0400)
committerGitHub <noreply@github.com>
Sun, 26 Mar 2023 19:32:08 +0000 (21:32 +0200)
* Initial contribution

Signed-off-by: Mark Hilbush <mark@hilbush.com>
17 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.anthem/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.anthem/README.md [new file with mode: 0644]
bundles/org.openhab.binding.anthem/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemCommandParser.java [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/Zone.java [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/i18n/anthem.properties [new file with mode: 0644]
bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/pom.xml

index 3dd8c6bc328f3d1ca17da4fdfaf24d6e7d9db248..6d685d130f3069fcf72863ba1e817f09a1a16d17 100644 (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
index c7a56633a3bb75dea975f59a1a013d5aa1419793..d28dbc58a7119126bee31f45d39e10bf5b45e78d 100644 (file)
       <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>
diff --git a/bundles/org.openhab.binding.anthem/NOTICE b/bundles/org.openhab.binding.anthem/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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
diff --git a/bundles/org.openhab.binding.anthem/README.md b/bundles/org.openhab.binding.anthem/README.md
new file mode 100644 (file)
index 0000000..0e84fab
--- /dev/null
@@ -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" }
+```
diff --git a/bundles/org.openhab.binding.anthem/pom.xml b/bundles/org.openhab.binding.anthem/pom.xml
new file mode 100644 (file)
index 0000000..58014ae
--- /dev/null
@@ -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>
diff --git a/bundles/org.openhab.binding.anthem/src/main/feature/feature.xml b/bundles/org.openhab.binding.anthem/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..4dda4cc
--- /dev/null
@@ -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>
diff --git a/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemBindingConstants.java b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemBindingConstants.java
new file mode 100644 (file)
index 0000000..a020f48
--- /dev/null
@@ -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 = ';';
+}
diff --git a/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemConfiguration.java b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemConfiguration.java
new file mode 100644 (file)
index 0000000..232ef9f
--- /dev/null
@@ -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 + " }";
+    }
+}
diff --git a/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemHandlerFactory.java b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemHandlerFactory.java
new file mode 100644 (file)
index 0000000..dfffd77
--- /dev/null
@@ -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;
+    }
+}
diff --git a/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemCommand.java b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemCommand.java
new file mode 100644 (file)
index 0000000..ed17979
--- /dev/null
@@ -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();
+    }
+}
diff --git a/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemCommandParser.java b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemCommandParser.java
new file mode 100644 (file)
index 0000000..8349093
--- /dev/null
@@ -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);
+            }
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemHandler.java b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemHandler.java
new file mode 100644 (file)
index 0000000..3f66d75
--- /dev/null
@@ -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");
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/Zone.java b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/Zone.java
new file mode 100644 (file)
index 0000000..ba5ec09
--- /dev/null
@@ -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;
+    }
+}
diff --git a/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644 (file)
index 0000000..642c0a1
--- /dev/null
@@ -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>
diff --git a/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/i18n/anthem.properties b/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/i18n/anthem.properties
new file mode 100644 (file)
index 0000000..fb89150
--- /dev/null
@@ -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
diff --git a/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..3398529
--- /dev/null
@@ -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>
index ed083d8c06907125b7a3fac042e448f261eb004d..65bb6d8345f311f6664e498c97aed98b8e83f7e4 100644 (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>