2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.denonmarantz.internal.connector.telnet;
15 import java.math.BigDecimal;
16 import java.util.ArrayList;
17 import java.util.Arrays;
18 import java.util.List;
19 import java.util.concurrent.Future;
20 import java.util.concurrent.ScheduledExecutorService;
21 import java.util.regex.Pattern;
23 import org.openhab.binding.denonmarantz.internal.DenonMarantzState;
24 import org.openhab.binding.denonmarantz.internal.config.DenonMarantzConfiguration;
25 import org.openhab.binding.denonmarantz.internal.connector.DenonMarantzConnector;
26 import org.slf4j.Logger;
27 import org.slf4j.LoggerFactory;
30 * This class makes the connection to the receiver and manages it.
31 * It is also responsible for sending commands to the receiver.
33 * @author Jeroen Idserda - Initial Contribution (1.x Binding)
34 * @author Jan-Willem Veldhuis - Refactored for 2.x
36 public class DenonMarantzTelnetConnector extends DenonMarantzConnector implements DenonMarantzTelnetListener {
38 private final Logger logger = LoggerFactory.getLogger(DenonMarantzTelnetConnector.class);
40 // All regular commands. Example: PW, SICD, SITV, Z2MU
41 private static final Pattern COMMAND_PATTERN = Pattern.compile("^([A-Z0-9]{2})(.+)$");
43 // Example: E2Counting Crows
44 private static final Pattern DISPLAY_PATTERN = Pattern.compile("^(E|A)([0-9]{1})(.+)$");
46 private static final BigDecimal NINETYNINE = new BigDecimal("99");
48 private DenonMarantzTelnetClientThread telnetClientThread;
50 private boolean displayNowplaying = false;
52 protected boolean disposing = false;
54 private Future<?> telnetStateRequest;
56 private String thingUID;
58 public DenonMarantzTelnetConnector(DenonMarantzConfiguration config, DenonMarantzState state,
59 ScheduledExecutorService scheduler, String thingUID) {
61 this.scheduler = scheduler;
63 this.thingUID = thingUID;
67 * Set up the connection to the receiver. Either using Telnet or by polling the HTTP API.
70 public void connect() {
71 telnetClientThread = new DenonMarantzTelnetClientThread(config, this);
72 telnetClientThread.setName("OH-binding-" + thingUID);
73 telnetClientThread.start();
77 public void telnetClientConnected(boolean connected) {
79 if (config.isTelnet() && !disposing) {
80 logger.debug("Telnet client disconnected.");
81 state.connectionError(
82 "Error connecting to the telnet port. Consider disabling telnet in this Thing's configuration to use HTTP polling instead.");
90 * Shutdown the telnet client (if initialized) and the http client
93 public void dispose() {
94 logger.debug("disposing connector");
97 if (telnetStateRequest != null) {
98 telnetStateRequest.cancel(true);
99 telnetStateRequest = null;
102 if (telnetClientThread != null) {
103 telnetClientThread.interrupt();
104 // Invoke a shutdown after interrupting the thread to close the socket immediately,
105 // otherwise the client keeps running until a line was received from the telnet connection
106 telnetClientThread.shutdown();
107 telnetClientThread = null;
111 private void refreshState() {
112 // Sends a series of state query commands over the telnet connection
113 telnetStateRequest = scheduler.submit(() -> {
114 List<String> cmds = new ArrayList<>(Arrays.asList("PW?", "MS?", "MV?", "ZM?", "MU?", "SI?"));
115 if (config.getZoneCount() > 1) {
119 if (config.getZoneCount() > 2) {
123 for (String cmd : cmds) {
124 internalSendCommand(cmd);
127 } catch (InterruptedException e) {
128 logger.trace("requestStateOverTelnet() - Interrupted while requesting state.");
129 Thread.currentThread().interrupt();
136 * This method tries to parse information received over the telnet connection.
137 * It can be quite unreliable. Some chars go missing or turn into other chars. That's
138 * why each command is validated using a regex.
140 * @param line The received command (one line)
143 public void receivedLine(String line) {
144 if (COMMAND_PATTERN.matcher(line).matches()) {
146 * This splits the commandString into the command and the parameter. SICD
147 * for example has SI as the command and CD as the parameter.
149 String command = line.substring(0, 2);
150 String value = line.substring(2, line.length()).trim();
152 logger.debug("Received Command: {}, value: {}", command, value);
154 // use received command (event) from telnet to update state
156 case "SI": // Switch Input
157 state.setInput(value);
160 if ("ON".equals(value) || "STANDBY".equals(value)) {
161 state.setPower("ON".equals(value));
164 case "MS": // Main zone surround program
165 state.setSurroundProgram(value);
167 case "MV": // Main zone volume
168 if (value.chars().allMatch(Character::isDigit)) {
169 state.setMainVolume(fromDenonValue(value));
172 case "MU": // Main zone mute
173 if ("ON".equals(value) || "OFF".equals(value)) {
174 state.setMute("ON".equals(value));
177 case "NS": // Now playing information
178 processTitleCommand(value);
181 if ("ON".equals(value) || "OFF".equals(value)) {
182 state.setZone2Power("ON".equals(value));
183 } else if ("MUON".equals(value) || "MUOFF".equals(value)) {
184 state.setZone2Mute("MUON".equals(value));
185 } else if (value.chars().allMatch(Character::isDigit)) {
186 state.setZone2Volume(fromDenonValue(value));
188 state.setZone2Input(value);
192 if ("ON".equals(value) || "OFF".equals(value)) {
193 state.setZone3Power("ON".equals(value));
194 } else if ("MUON".equals(value) || "MUOFF".equals(value)) {
195 state.setZone3Mute("MUON".equals(value));
196 } else if (value.chars().allMatch(Character::isDigit)) {
197 state.setZone3Volume(fromDenonValue(value));
199 state.setZone3Input(value);
203 if ("ON".equals(value) || "OFF".equals(value)) {
204 state.setZone4Power("ON".equals(value));
205 } else if ("MUON".equals(value) || "MUOFF".equals(value)) {
206 state.setZone4Mute("MUON".equals(value));
207 } else if (value.chars().allMatch(Character::isDigit)) {
208 state.setZone4Volume(fromDenonValue(value));
210 state.setZone4Input(value);
213 case "ZM": // Main zone
214 if ("ON".equals(value) || "OFF".equals(value)) {
215 state.setMainZonePower("ON".equals(value));
220 logger.trace("Ignoring received line: '{}'", line);
224 private BigDecimal fromDenonValue(String string) {
231 BigDecimal value = new BigDecimal(string);
232 if (value.compareTo(NINETYNINE) == 1 || (string.startsWith("0") && string.length() > 2)) {
233 value = value.divide(BigDecimal.TEN);
238 private void processTitleCommand(String value) {
239 if (DISPLAY_PATTERN.matcher(value).matches()) {
240 Integer commandNo = Integer.valueOf(value.substring(1, 2));
241 String titleValue = value.substring(2);
243 if (commandNo == 0) {
244 displayNowplaying = titleValue.contains("Now Playing");
247 String nowPlaying = displayNowplaying ? cleanupDisplayInfo(titleValue) : "";
251 state.setNowPlayingTrack(nowPlaying);
254 state.setNowPlayingArtist(nowPlaying);
257 state.setNowPlayingAlbum(nowPlaying);
264 protected void internalSendCommand(String command) {
265 logger.debug("Sending command '{}'", command);
266 if (command == null || command.isBlank()) {
267 logger.warn("Trying to send empty command");
270 telnetClientThread.sendCommand(command);
274 * Display info could contain some garbled text, attempt to clean it up.
276 private String cleanupDisplayInfo(String titleValue) {
277 byte[] firstByteRemoved = Arrays.copyOfRange(titleValue.getBytes(), 1, titleValue.getBytes().length);
278 return new String(firstByteRemoved).replaceAll("[\u0000-\u001f]", "");