]> git.basschouten.com Git - openhab-addons.git/blob
fdbf3444a8db7ccd381bf5364b65e52b924509f5
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.pjlinkdevice.internal.device;
14
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;
30 import java.util.Map;
31 import java.util.Set;
32
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;
61
62 /**
63  * Represents a PJLink device and takes care of managing the TCP connection, executing commands, and authentication.
64  *
65  * The central interface to get information about and set status on the device.
66  *
67  * @author Nils Schnabel - Initial contribution
68  */
69 @NonNullByDefault
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));
83
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;
89     }
90
91     public PJLinkDevice(int tcpPort, InetAddress ipAddress, @Nullable String adminPassword) {
92         this(tcpPort, ipAddress, adminPassword, TIMEOUT);
93     }
94
95     @Override
96     public String toString() {
97         return "PJLink " + this.ipAddress + ":" + this.tcpPort;
98     }
99
100     protected Socket connect() throws IOException, ResponseException, AuthenticationException {
101         return connect(false);
102     }
103
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()));
108         }
109         return reader;
110     }
111
112     protected void closeSocket(@Nullable Socket socket) {
113         if (socket != null) {
114             try {
115                 socket.close();
116             } catch (IOException e) {
117                 // okay then, at least we tried
118                 logger.trace("closing of socket failed", e);
119             }
120         }
121         this.socket = null;
122         this.reader = null;
123     }
124
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;
135         }
136
137         if (forceReconnect || connectionTooOld) {
138             if (socket != null) {
139                 closeSocket(socket);
140             }
141         }
142
143         this.socketCreatedOn = now;
144         if (socket != null && socket.isConnected() && !socket.isClosed()) {
145             return socket;
146         }
147
148         SocketAddress socketAddress = new InetSocketAddress(ipAddress, tcpPort);
149
150         try {
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");
158             }
159             String header = rawHeader.toUpperCase();
160             switch (header.substring(0, "PJLINK x".length())) {
161                 case "PJLINK 0":
162                     logger.debug("Authentication not needed");
163                     this.authenticationRequired = false;
164                     break;
165                 case "PJLINK 1":
166                     logger.debug("Authentication needed");
167                     this.authenticationRequired = true;
168                     if (this.adminPassword == null) {
169                         closeSocket(socket);
170                         throw new AuthenticationException("No password provided, but device requires authentication");
171                     } else {
172                         try {
173                             authenticate(rawHeader.substring("PJLINK 1 ".length()));
174                         } catch (AuthenticationException e) {
175                             // propagate AuthenticationException
176                             throw e;
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
180                         }
181                     }
182                     break;
183                 default:
184                     logger.debug("Cannot handle introduction response {}", header);
185                     throw new ResponseException("Invalid header: " + header);
186             }
187             return socket;
188         } catch (ConnectException | SocketTimeoutException | NoRouteToHostException e) {
189             // these exceptions indicate that there's no device at this address, just throw without logging
190             throw e;
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);
195             throw e;
196         }
197     }
198
199     private void authenticate(String challenge) throws ResponseException, IOException, AuthenticationException {
200         new AuthenticationCommand<>(this, challenge, new PowerQueryCommand(this)).execute();
201     }
202
203     public PowerQueryResponse getPowerStatus() throws ResponseException, IOException, AuthenticationException {
204         return new PowerQueryCommand(this).execute();
205     }
206
207     public void addPrefixToNextCommand(String cmd) throws IOException, AuthenticationException {
208         this.prefixForNextCommand = cmd;
209     }
210
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*$", "");
214     }
215
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++) {
220             try {
221                 Socket socket = connect();
222                 socket.getOutputStream().write((fullCommand).getBytes());
223                 socket.getOutputStream().flush();
224
225                 // success, no further tries needed
226                 break;
227             } catch (java.net.SocketException e) {
228                 closeSocket(socket);
229                 if (numberOfTries >= 2) {
230                     // do not retry endlessly
231                     throw e;
232                 }
233             }
234         }
235
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"));
240         }
241         if (response == null) {
242             throw new ResponseException(MessageFormat.format("Response to request ''{0}'' was null",
243                     fullCommand.replaceAll("\r", "\\\\r")));
244         }
245
246         if (logger.isDebugEnabled()) {
247             logger.debug("Got response '{}' ({}) for request '{}' from {}", response,
248                     Arrays.toString(response.getBytes()), fullCommand.replaceAll("\r", "\\\\r"), ipAddress);
249         }
250         return preprocessResponse(response);
251     }
252
253     public void checkAvailability() throws IOException, AuthenticationException, ResponseException {
254         connect();
255     }
256
257     public String getName() throws IOException, ResponseException, AuthenticationException {
258         return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.NAME).execute().getResult();
259     }
260
261     public String getManufacturer() throws IOException, ResponseException, AuthenticationException {
262         return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.MANUFACTURER).execute()
263                 .getResult();
264     }
265
266     public String getModel() throws IOException, ResponseException, AuthenticationException {
267         return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.MODEL).execute()
268                 .getResult();
269     }
270
271     public String getFullDescription() throws AuthenticationException, ResponseException {
272         StringBuilder sb = new StringBuilder();
273         try {
274             sb.append(getManufacturer());
275             sb.append(" ");
276         } catch (ResponseException | IOException e) {
277             // okay, we'll try the other identification commands
278         }
279
280         try {
281             sb.append(getModel());
282         } catch (ResponseException | IOException e1) {
283             // okay, we'll try the other identification commands
284         }
285
286         try {
287             String name = getName();
288             if (!name.isEmpty()) {
289                 sb.append(": ");
290                 sb.append(name);
291             }
292         } catch (ResponseException | IOException e2) {
293             // okay, we'll try the other identification commands
294         }
295
296         if (sb.length() == 0) {
297             throw new ResponseException("None of the identification commands worked");
298         }
299
300         return sb.toString();
301     }
302
303     public String getPJLinkClass() throws IOException, AuthenticationException, ResponseException {
304         return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.CLASS).execute()
305                 .getResult();
306     }
307
308     public void powerOn() throws ResponseException, IOException, AuthenticationException {
309         new PowerInstructionCommand(this, PowerInstructionCommand.PowerInstructionState.ON).execute();
310     }
311
312     public void powerOff() throws IOException, ResponseException, AuthenticationException {
313         new PowerInstructionCommand(this, PowerInstructionCommand.PowerInstructionState.OFF).execute();
314     }
315
316     public @Nullable String getAdminPassword() {
317         return this.adminPassword;
318     }
319
320     public Boolean getAuthenticationRequired() {
321         return this.authenticationRequired;
322     }
323
324     public InputQueryResponse getInputStatus() throws ResponseException, IOException, AuthenticationException {
325         return new InputQueryCommand(this).execute();
326     }
327
328     public void setInput(Input input) throws ResponseException, IOException, AuthenticationException {
329         new InputInstructionCommand(this, input).execute();
330     }
331
332     public MuteQueryResponseValue getMuteStatus() throws ResponseException, IOException, AuthenticationException {
333         return new MuteQueryCommand(this).execute().getResult();
334     }
335
336     public void setMute(MuteInstructionChannel channel, boolean muteOn)
337             throws ResponseException, IOException, AuthenticationException {
338         new MuteInstructionCommand(this, muteOn ? MuteInstructionState.ON : MuteInstructionState.OFF, channel)
339                 .execute();
340     }
341
342     public Map<ErrorStatusDevicePart, ErrorStatusQueryResponseState> getErrorStatus()
343             throws ResponseException, IOException, AuthenticationException {
344         return new ErrorStatusQueryCommand(this).execute().getResult();
345     }
346
347     public List<LampState> getLampStates() throws ResponseException, IOException, AuthenticationException {
348         return new LampStatesCommand(this).execute().getResult();
349     }
350
351     public List<LampState> getLampStatesCached() throws ResponseException, IOException, AuthenticationException {
352         return cachedLampHoursCommand.execute().getResult();
353     }
354
355     public String getOtherInformation() throws ResponseException, IOException, AuthenticationException {
356         return new IdentificationCommand(this, IdentificationCommand.IdentificationProperty.OTHER_INFORMATION).execute()
357                 .getResult();
358     }
359
360     public Set<Input> getAvailableInputs() throws ResponseException, IOException, AuthenticationException {
361         return new InputListQueryCommand(this).execute().getResult();
362     }
363
364     public void dispose() {
365         final Socket socket = this.socket;
366         if (socket != null) {
367             closeSocket(socket);
368         }
369     }
370 }