diff --git a/CODEOWNERS b/CODEOWNERS index 3dd8c6bc328f3..6d685d130f306 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index c7a56633a3bb7..d28dbc58a7119 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -116,6 +116,11 @@ org.openhab.binding.anel ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.anthem + ${project.version} + org.openhab.addons.bundles org.openhab.binding.astro diff --git a/bundles/org.openhab.binding.anthem/NOTICE b/bundles/org.openhab.binding.anthem/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.anthem/NOTICE @@ -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 index 0000000000000..0e84fab9d2b82 --- /dev/null +++ b/bundles/org.openhab.binding.anthem/README.md @@ -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 index 0000000000000..58014ae40a64e --- /dev/null +++ b/bundles/org.openhab.binding.anthem/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.0.0-SNAPSHOT + + + org.openhab.binding.anthem + + openHAB Add-ons :: Bundles :: Anthem Binding + + 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 index 0000000000000..4dda4ccbc0855 --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/feature/feature.xml @@ -0,0 +1,10 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.anthem/${project.version} + + 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 index 0000000000000..a020f485f70c7 --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemBindingConstants.java @@ -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 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 index 0000000000000..232ef9f2a6233 --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemConfiguration.java @@ -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 index 0000000000000..dfffd7739648a --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/AnthemHandlerFactory.java @@ -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 index 0000000000000..ed1797912115d --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemCommand.java @@ -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 index 0000000000000..8349093013b22 --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemCommandParser.java @@ -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 inputShortNamesMap = new HashMap<>(); + private Map 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 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 index 0000000000000..3f66d753e335c --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/AnthemHandler.java @@ -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 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 index 0000000000000..ba5ec097d2515 --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/java/org/openhab/binding/anthem/internal/handler/Zone.java @@ -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 index 0000000000000..642c0a14245ae --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,11 @@ + + + + binding + Anthem Binding + This is the binding for Anthem AV preamp/processors + local + + 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 index 0000000000000..fb89150d1939a --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/i18n/anthem.properties @@ -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 index 0000000000000..33985294ba650 --- /dev/null +++ b/bundles/org.openhab.binding.anthem/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,96 @@ + + + + + + Thing for Anthem AV processor + + + + + Controls zone 1 (the main zone) of the processor + + + + + Controls zone 2 of the processor + + + + + + + Host name or IP address of the Anthem AV processor + network-address + + + + + Network port number of the Anthem AV processor + 14999 + true + + + + + The time to wait between reconnection attempts (in minutes) + 2 + true + + + + + The delay between commands sent to the processor (in milliseconds) + 100 + true + + + + + + + Channels for a zone of this processor + + + + + + + + + + + + + + Number + + Set the volume level dB between -90 and 0 + SoundVolume + + + + + Number + + Selects the active input source + + + + String + + Short friendly name of the active input source + + + + + String + + Long friendly name of the active input source + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index ed083d8c06907..65bb6d8345f31 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -58,6 +58,7 @@ org.openhab.binding.amplipi org.openhab.binding.androiddebugbridge org.openhab.binding.anel + org.openhab.binding.anthem org.openhab.binding.astro org.openhab.binding.atlona org.openhab.binding.autelis