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.pjlinkdevice.internal.device;
15 import java.io.BufferedReader;
16 import java.io.IOException;
17 import java.io.InputStreamReader;
18 import java.net.ConnectException;
19 import java.net.InetAddress;
20 import java.net.InetSocketAddress;
21 import java.net.NoRouteToHostException;
22 import java.net.Socket;
23 import java.net.SocketAddress;
24 import java.net.SocketTimeoutException;
25 import java.text.MessageFormat;
26 import java.time.Duration;
27 import java.time.Instant;
28 import java.util.Arrays;
29 import java.util.List;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.pjlinkdevice.internal.device.command.AuthenticationException;
36 import org.openhab.binding.pjlinkdevice.internal.device.command.CachedCommand;
37 import org.openhab.binding.pjlinkdevice.internal.device.command.ResponseException;
38 import org.openhab.binding.pjlinkdevice.internal.device.command.authentication.AuthenticationCommand;
39 import org.openhab.binding.pjlinkdevice.internal.device.command.errorstatus.ErrorStatusQueryCommand;
40 import org.openhab.binding.pjlinkdevice.internal.device.command.errorstatus.ErrorStatusQueryResponse.ErrorStatusDevicePart;
41 import org.openhab.binding.pjlinkdevice.internal.device.command.errorstatus.ErrorStatusQueryResponse.ErrorStatusQueryResponseState;
42 import org.openhab.binding.pjlinkdevice.internal.device.command.identification.IdentificationCommand;
43 import org.openhab.binding.pjlinkdevice.internal.device.command.input.Input;
44 import org.openhab.binding.pjlinkdevice.internal.device.command.input.InputInstructionCommand;
45 import org.openhab.binding.pjlinkdevice.internal.device.command.input.InputListQueryCommand;
46 import org.openhab.binding.pjlinkdevice.internal.device.command.input.InputQueryCommand;
47 import org.openhab.binding.pjlinkdevice.internal.device.command.input.InputQueryResponse;
48 import org.openhab.binding.pjlinkdevice.internal.device.command.lampstatus.LampStatesCommand;
49 import org.openhab.binding.pjlinkdevice.internal.device.command.lampstatus.LampStatesResponse;
50 import org.openhab.binding.pjlinkdevice.internal.device.command.lampstatus.LampStatesResponse.LampState;
51 import org.openhab.binding.pjlinkdevice.internal.device.command.mute.MuteInstructionCommand;
52 import org.openhab.binding.pjlinkdevice.internal.device.command.mute.MuteInstructionCommand.MuteInstructionChannel;
53 import org.openhab.binding.pjlinkdevice.internal.device.command.mute.MuteInstructionCommand.MuteInstructionState;
54 import org.openhab.binding.pjlinkdevice.internal.device.command.mute.MuteQueryCommand;
55 import org.openhab.binding.pjlinkdevice.internal.device.command.mute.MuteQueryResponse.MuteQueryResponseValue;
56 import org.openhab.binding.pjlinkdevice.internal.device.command.power.PowerInstructionCommand;
57 import org.openhab.binding.pjlinkdevice.internal.device.command.power.PowerQueryCommand;
58 import org.openhab.binding.pjlinkdevice.internal.device.command.power.PowerQueryResponse;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
63 * Represents a PJLink device and takes care of managing the TCP connection, executing commands, and authentication.
65 * The central interface to get information about and set status on the device.
67 * @author Nils Schnabel - Initial contribution
70 public class PJLinkDevice {
71 private static final int TIMEOUT = 30000;
72 protected int tcpPort;
73 protected InetAddress ipAddress;
74 protected @Nullable String adminPassword;
75 protected boolean authenticationRequired;
76 protected @Nullable BufferedReader reader;
77 protected @Nullable Socket socket;
78 protected int timeout = TIMEOUT;
79 private final Logger logger = LoggerFactory.getLogger(PJLinkDevice.class);
80 private String prefixForNextCommand = "";
81 private @Nullable Instant socketCreatedOn;
82 private CachedCommand<LampStatesResponse> cachedLampHoursCommand = new CachedCommand<>(new LampStatesCommand(this));
84 public PJLinkDevice(int tcpPort, InetAddress ipAddress, @Nullable String adminPassword, int timeout) {
85 this.tcpPort = tcpPort;
86 this.ipAddress = ipAddress;
87 this.adminPassword = adminPassword;
88 this.timeout = timeout;
91 public PJLinkDevice(int tcpPort, InetAddress ipAddress, @Nullable String adminPassword) {
92 this(tcpPort, ipAddress, adminPassword, TIMEOUT);
96 public String toString() {
97 return "PJLink " + this.ipAddress + ":" + this.tcpPort;
100 protected Socket connect() throws IOException, ResponseException, AuthenticationException {
101 return connect(false);
104 protected BufferedReader getReader() throws IOException, ResponseException, AuthenticationException {
105 BufferedReader reader = this.reader;
106 if (reader == null) {
107 this.reader = reader = new BufferedReader(new InputStreamReader(connect().getInputStream()));
112 protected void closeSocket(@Nullable Socket socket) {
113 if (socket != null) {
116 } catch (IOException e) {
117 // okay then, at least we tried
118 logger.trace("closing of socket failed", e);
125 protected Socket connect(boolean forceReconnect) throws IOException, ResponseException, AuthenticationException {
126 Instant now = Instant.now();
127 Socket socket = this.socket;
128 boolean connectionTooOld = false;
129 Instant socketCreatedOn = this.socketCreatedOn;
130 if (socketCreatedOn != null) {
131 long millisecondsSinceLastConnect = Duration.between(socketCreatedOn, now).toMillis();
132 // according to the PJLink specification, the device closes the connection after 30s idle (without notice),
133 // so to be on the safe side we do not reuse sockets older than 20s
134 connectionTooOld = millisecondsSinceLastConnect > 20 * 1000;
137 if (forceReconnect || connectionTooOld) {
138 if (socket != null) {
143 this.socketCreatedOn = now;
144 if (socket != null && socket.isConnected() && !socket.isClosed()) {
148 SocketAddress socketAddress = new InetSocketAddress(ipAddress, tcpPort);
151 this.socket = socket = new Socket();
152 socket.connect(socketAddress, timeout);
153 socket.setSoTimeout(timeout);
154 BufferedReader reader = getReader();
155 String rawHeader = reader.readLine();
156 if (rawHeader == null) {
157 throw new ResponseException("No PJLink header received from the device");
159 String header = rawHeader.toUpperCase();
160 switch (header.substring(0, "PJLINK x".length())) {
162 logger.debug("Authentication not needed");
163 this.authenticationRequired = false;
166 logger.debug("Authentication needed");
167 this.authenticationRequired = true;
168 if (this.adminPassword == null) {
170 throw new AuthenticationException("No password provided, but device requires authentication");
173 authenticate(rawHeader.substring("PJLINK 1 ".length()));
174 } catch (AuthenticationException e) {
175 // propagate AuthenticationException
177 } catch (ResponseException e) {
178 // maybe only the test command is broken on the device
179 // as long as we don't get an AuthenticationException, we'll just ignore it for now
184 logger.debug("Cannot handle introduction response {}", header);
185 throw new ResponseException("Invalid header: " + header);
188 } catch (ConnectException | SocketTimeoutException | NoRouteToHostException e) {
189 // these exceptions indicate that there's no device at this address, just throw without logging
191 } catch (IOException | ResponseException e) {
192 // these exceptions seem to be more interesting in the log during a scan
193 // This should not happen and might be a user configuration issue, we log a warning message therefore.
194 logger.debug("Could not create a socket connection", e);
199 private void authenticate(String challenge) throws ResponseException, IOException, AuthenticationException {
200 new AuthenticationCommand<>(this, challenge, new PowerQueryCommand(this)).execute();
203 public PowerQueryResponse getPowerStatus() throws ResponseException, IOException, AuthenticationException {
204 return new PowerQueryCommand(this).execute();
207 public void addPrefixToNextCommand(String cmd) throws IOException, AuthenticationException {
208 this.prefixForNextCommand = cmd;
211 public static String preprocessResponse(String response) {
212 // some devices send leading zero bytes, see https://github.com/openhab/openhab-addons/issues/6725
213 return response.replaceAll("^\0*|\0*$", "");
216 public synchronized String execute(String command) throws IOException, AuthenticationException, ResponseException {
217 String fullCommand = this.prefixForNextCommand + command;
218 this.prefixForNextCommand = "";
219 for (int numberOfTries = 0; true; numberOfTries++) {
221 Socket socket = connect();
222 socket.getOutputStream().write((fullCommand).getBytes());
223 socket.getOutputStream().flush();
225 // success, no further tries needed
227 } catch (java.net.SocketException e) {
229 if (numberOfTries >= 2) {
230 // do not retry endlessly
236 String response = null;
237 while ((response = getReader().readLine()) != null && preprocessResponse(response).isEmpty()) {
238 logger.debug("Got empty string response for request '{}' from {}, waiting for another line", response,
239 fullCommand.replaceAll("\r", "\\\\r"));
241 if (response == null) {
242 throw new ResponseException(MessageFormat.format("Response to request ''{0}'' was null",
243 fullCommand.replaceAll("\r", "\\\\r")));
246 if (logger.isDebugEnabled()) {
247 logger.debug("Got response '{}' ({}) for request '{}' from {}", response,
248 Arrays.toString(response.getBytes()), fullCommand.replaceAll("\r", "\\\\r"), ipAddress);
250 return preprocessResponse(response);
253 public void checkAvailability() throws IOException, AuthenticationException, ResponseException {
257 public String getName() throws IOException, ResponseException, AuthenticationException {
258 return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.NAME).execute().getResult();
261 public String getManufacturer() throws IOException, ResponseException, AuthenticationException {
262 return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.MANUFACTURER).execute()
266 public String getModel() throws IOException, ResponseException, AuthenticationException {
267 return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.MODEL).execute()
271 public String getFullDescription() throws AuthenticationException, ResponseException {
272 StringBuilder sb = new StringBuilder();
274 sb.append(getManufacturer());
276 } catch (ResponseException | IOException e) {
277 // okay, we'll try the other identification commands
281 sb.append(getModel());
282 } catch (ResponseException | IOException e1) {
283 // okay, we'll try the other identification commands
287 String name = getName();
288 if (!name.isEmpty()) {
292 } catch (ResponseException | IOException e2) {
293 // okay, we'll try the other identification commands
296 if (sb.length() == 0) {
297 throw new ResponseException("None of the identification commands worked");
300 return sb.toString();
303 public String getPJLinkClass() throws IOException, AuthenticationException, ResponseException {
304 return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.CLASS).execute()
308 public void powerOn() throws ResponseException, IOException, AuthenticationException {
309 new PowerInstructionCommand(this, PowerInstructionCommand.PowerInstructionState.ON).execute();
312 public void powerOff() throws IOException, ResponseException, AuthenticationException {
313 new PowerInstructionCommand(this, PowerInstructionCommand.PowerInstructionState.OFF).execute();
316 public @Nullable String getAdminPassword() {
317 return this.adminPassword;
320 public Boolean getAuthenticationRequired() {
321 return this.authenticationRequired;
324 public InputQueryResponse getInputStatus() throws ResponseException, IOException, AuthenticationException {
325 return new InputQueryCommand(this).execute();
328 public void setInput(Input input) throws ResponseException, IOException, AuthenticationException {
329 new InputInstructionCommand(this, input).execute();
332 public MuteQueryResponseValue getMuteStatus() throws ResponseException, IOException, AuthenticationException {
333 return new MuteQueryCommand(this).execute().getResult();
336 public void setMute(MuteInstructionChannel channel, boolean muteOn)
337 throws ResponseException, IOException, AuthenticationException {
338 new MuteInstructionCommand(this, muteOn ? MuteInstructionState.ON : MuteInstructionState.OFF, channel)
342 public Map<ErrorStatusDevicePart, ErrorStatusQueryResponseState> getErrorStatus()
343 throws ResponseException, IOException, AuthenticationException {
344 return new ErrorStatusQueryCommand(this).execute().getResult();
347 public List<LampState> getLampStates() throws ResponseException, IOException, AuthenticationException {
348 return new LampStatesCommand(this).execute().getResult();
351 public List<LampState> getLampStatesCached() throws ResponseException, IOException, AuthenticationException {
352 return cachedLampHoursCommand.execute().getResult();
355 public String getOtherInformation() throws ResponseException, IOException, AuthenticationException {
356 return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.OTHER_INFORMATION).execute()
360 public Set<Input> getAvailableInputs() throws ResponseException, IOException, AuthenticationException {
361 return new InputListQueryCommand(this).execute().getResult();
364 public void dispose() {
365 final Socket socket = this.socket;
366 if (socket != null) {