]> git.basschouten.com Git - openhab-addons.git/blob
51581a9c8f9894822893342e02ec2cff9473da1f
[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.tivo.internal.service;
14
15 import static org.openhab.binding.tivo.internal.TiVoBindingConstants.CONFIG_SOCKET_TIMEOUT_MS;
16
17 import java.io.BufferedReader;
18 import java.io.IOException;
19 import java.io.InputStream;
20 import java.io.InputStreamReader;
21 import java.io.PrintStream;
22 import java.net.Socket;
23 import java.net.SocketException;
24 import java.net.SocketTimeoutException;
25 import java.net.UnknownHostException;
26 import java.util.concurrent.TimeUnit;
27 import java.util.regex.Matcher;
28 import java.util.regex.Pattern;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.tivo.internal.handler.TiVoHandler;
33 import org.openhab.binding.tivo.internal.service.TivoStatusData.ConnectionStatus;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
36
37 /**
38  * TivoStatusProvider class to maintain a connection out to the Tivo, monitor and process status messages returned..
39  *
40  * @author Jayson Kubilis - Initial contribution
41  * @author Andrew Black - Updates / compilation corrections
42  * @author Michael Lobstein - Updated for OH3
43  */
44
45 @NonNullByDefault
46 public class TivoStatusProvider {
47     private static final Pattern TIVO_STATUS_PATTERN = Pattern.compile("^CH_STATUS (\\d{4}) (?:(\\d{4}))?");
48     private static final int TIMEOUT_SEC = 3000;
49
50     private final Logger logger = LoggerFactory.getLogger(TivoStatusProvider.class);
51     private @Nullable Socket tivoSocket = null;
52     private @Nullable PrintStream streamWriter = null;
53     private @Nullable StreamReader streamReader = null;
54     private @Nullable TiVoHandler tivoHandler = null;
55     private TivoStatusData tivoStatusData = new TivoStatusData();
56     private TivoConfigData tivoConfigData = new TivoConfigData();
57     private final String thingUid;
58
59     /**
60      * Instantiates a new TivoConfigStatusProvider.
61      *
62      * @param tivoConfigData {@link TivoConfigData} configuration data for the specific thing.
63      * @param tivoStatusData {@link TivoStatusData} status data for the specific thing.
64      * @param tivoHandler {@link TivoHandler} parent handler object for the TivoConfigStatusProvider.
65      *
66      */
67
68     public TivoStatusProvider(TivoConfigData tivoConfigData, TiVoHandler tivoHandler) {
69         this.tivoStatusData = new TivoStatusData(false, -1, -1, false, "INITIALISING", false, ConnectionStatus.UNKNOWN);
70         this.tivoConfigData = tivoConfigData;
71         this.tivoHandler = tivoHandler;
72         this.thingUid = tivoHandler.getThing().getUID().getAsString();
73     }
74
75     /**
76      * {@link statusRefresh} initiates a connection to the TiVo. When a new connection is made and the TiVo is online,
77      * the current channel is always returned. The connection is then closed (allows the socket to be used by other
78      * devices).
79      *
80      * @return {@link TivoStatusData} object
81      * @throws InterruptedException
82      */
83     public void statusRefresh() throws InterruptedException {
84         if (tivoStatusData.getConnectionStatus() != ConnectionStatus.INIT) {
85             logger.debug(" statusRefresh '{}' - EXISTING status data - '{}'", tivoConfigData.getCfgIdentifier(),
86                     tivoStatusData.toString());
87         }
88
89         // this will close the connection and re-open every 12 hours
90         if (tivoConfigData.isKeepConnActive()) {
91             connTivoDisconnect();
92             doNappTime();
93         }
94
95         connTivoConnect();
96         doNappTime();
97         if (!tivoConfigData.isKeepConnActive()) {
98             connTivoDisconnect();
99         }
100     }
101
102     /**
103      * {@link cmdTivoSend} sends a command to the Tivo.
104      *
105      * @param tivoCommand the complete command string (KEYWORD + PARAMETERS e.g. SETCH 102) to send.
106      * @return {@link TivoStatusData} status data object, contains the result of the command.
107      * @throws InterruptedException
108      */
109     public @Nullable TivoStatusData cmdTivoSend(String tivoCommand) throws InterruptedException {
110         boolean connected = connTivoConnect();
111         PrintStream streamWriter = this.streamWriter;
112
113         if (!connected || streamWriter == null) {
114             return new TivoStatusData(false, -1, -1, false, "CONNECTION FAILED", false, ConnectionStatus.OFFLINE);
115         }
116         logger.debug("TiVo '{}' - sending command: '{}'", tivoConfigData.getCfgIdentifier(), tivoCommand);
117         int repeatCount = 1;
118         // Handle special keyboard "repeat" commands
119         if (tivoCommand.contains("*")) {
120             repeatCount = Integer.parseInt(tivoCommand.substring(tivoCommand.indexOf("*") + 1));
121             tivoCommand = tivoCommand.substring(0, tivoCommand.indexOf("*"));
122             logger.debug("TiVo '{}' - repeating command: '{}' for '{}' times", tivoConfigData.getCfgIdentifier(),
123                     tivoCommand, repeatCount);
124         }
125         for (int i = 1; i <= repeatCount; i++) {
126             // Send the command
127             streamWriter.println(tivoCommand + "\r");
128             if (streamWriter.checkError()) {
129                 logger.debug("TiVo '{}' - called cmdTivoSend and encountered an IO error",
130                         tivoConfigData.getCfgIdentifier());
131                 tivoStatusData = new TivoStatusData(false, -1, -1, false, "CONNECTION FAILED", false,
132                         ConnectionStatus.OFFLINE);
133                 connTivoReconnect();
134             }
135         }
136         return tivoStatusData;
137     }
138
139     /**
140      * {@link statusParse} processes the {@link TivoStatusData} status message returned from the TiVo.
141      *
142      * For channel status messages form 'CH_STATUS channel reason' or 'CH_STATUS channel sub-channel reason' calls
143      * {@link getParsedChannel} and returns the channel number (if a match is found in a valid formatted message).
144      *
145      * @param rawStatus string representing the message text returned by the TiVo
146      * @return TivoStatusData object conditionally populated based upon the raw status message
147      */
148     private TivoStatusData statusParse(String rawStatus) {
149         logger.debug(" statusParse '{}' - running on string '{}'", tivoConfigData.getCfgIdentifier(), rawStatus);
150
151         if (rawStatus.contentEquals("COMMAND_TIMEOUT")) {
152             // If connection status was UNKNOWN, COMMAND_TIMEOUT indicates the Tivo is alive, so update the status
153             if (this.tivoStatusData.getConnectionStatus() == ConnectionStatus.UNKNOWN) {
154                 return new TivoStatusData(false, -1, -1, false, "COMMAND_TIMEOUT", false, ConnectionStatus.ONLINE);
155             }
156             // Otherwise ignore COMMAND_TIMEOUT, they occur a few seconds after each successful command, just return
157             // existing status again
158             return this.tivoStatusData;
159         } else {
160             switch (rawStatus) {
161                 case "":
162                     return new TivoStatusData(false, -1, -1, false, "NO_STATUS_DATA_RETURNED", false,
163                             tivoStatusData.getConnectionStatus());
164                 case "LIVETV_READY":
165                     return new TivoStatusData(true, -1, -1, false, "LIVETV_READY", true, ConnectionStatus.ONLINE);
166                 case "CH_FAILED NO_LIVE":
167                     return new TivoStatusData(false, -1, -1, false, "CH_FAILED NO_LIVE", true,
168                             ConnectionStatus.STANDBY);
169                 case "CH_FAILED RECORDING":
170                 case "CH_FAILED MISSING_CHANNEL":
171                 case "CH_FAILED MALFORMED_CHANNEL":
172                 case "CH_FAILED INVALID_CHANNEL":
173                     return new TivoStatusData(false, -1, -1, false, rawStatus, true, ConnectionStatus.ONLINE);
174                 case "INVALID_COMMAND":
175                     return new TivoStatusData(false, -1, -1, false, "INVALID_COMMAND", false, ConnectionStatus.ONLINE);
176                 case "CONNECTION_RETRIES_EXHAUSTED":
177                     return new TivoStatusData(false, -1, -1, false, "CONNECTION_RETRIES_EXHAUSTED", true,
178                             ConnectionStatus.OFFLINE);
179             }
180         }
181
182         // Only other documented status is in the form 'CH_STATUS channel reason' or
183         // 'CH_STATUS channel sub-channel reason'
184         Matcher matcher = TIVO_STATUS_PATTERN.matcher(rawStatus);
185         int chNum = -1; // -1 used globally to indicate channel number error
186         int subChNum = -1;
187         boolean isRecording = false;
188
189         if (matcher.find()) {
190             logger.debug(" statusParse '{}' - groups '{}' with group count of '{}'", tivoConfigData.getCfgIdentifier(),
191                     matcher.group(), matcher.groupCount());
192             if (matcher.groupCount() == 1 || matcher.groupCount() == 2) {
193                 chNum = Integer.parseInt(matcher.group(1).trim());
194                 logger.debug(" statusParse '{}' - parsed channel '{}'", tivoConfigData.getCfgIdentifier(), chNum);
195             }
196             if (matcher.groupCount() == 2 && matcher.group(2) != null) {
197                 subChNum = Integer.parseInt(matcher.group(2).trim());
198                 logger.debug(" statusParse '{}' - parsed sub-channel '{}'", tivoConfigData.getCfgIdentifier(),
199                         subChNum);
200             }
201
202             if (rawStatus.contains("RECORDING")) {
203                 isRecording = true;
204             }
205
206             rawStatus = rawStatus.replace(" REMOTE", "");
207             rawStatus = rawStatus.replace(" LOCAL", "");
208             return new TivoStatusData(true, chNum, subChNum, isRecording, rawStatus, true, ConnectionStatus.ONLINE);
209         }
210         logger.warn(" TiVo '{}' - Unhandled/unexpected status message: '{}'", tivoConfigData.getCfgIdentifier(),
211                 rawStatus);
212         return new TivoStatusData(false, -1, -1, false, rawStatus, false, tivoStatusData.getConnectionStatus());
213     }
214
215     /**
216      * {@link connIsConnected} returns the connection state of the Socket, streamWriter and streamReader objects.
217      *
218      * @return true = connection exists and all objects look OK, false = connection does not exist or a problem has
219      *         occurred
220      *
221      */
222     private boolean connIsConnected() {
223         Socket tivoSocket = this.tivoSocket;
224         PrintStream streamWriter = this.streamWriter;
225
226         if (tivoSocket == null) {
227             logger.debug(" connIsConnected '{}' - FALSE: tivoSocket=null", tivoConfigData.getCfgIdentifier());
228             return false;
229         } else if (!tivoSocket.isConnected()) {
230             logger.debug(" connIsConnected '{}' - FALSE: tivoSocket.isConnected=false",
231                     tivoConfigData.getCfgIdentifier());
232             return false;
233         } else if (tivoSocket.isClosed()) {
234             logger.debug(" connIsConnected '{}' - FALSE: tivoSocket.isClosed=true", tivoConfigData.getCfgIdentifier());
235             return false;
236         } else if (streamWriter == null) {
237             logger.debug(" connIsConnected '{}' - FALSE: tivoIOSendCommand=null", tivoConfigData.getCfgIdentifier());
238             return false;
239         } else if (streamWriter.checkError()) {
240             logger.debug(" connIsConnected '{}' - FALSE: tivoIOSendCommand.checkError()=true",
241                     tivoConfigData.getCfgIdentifier());
242             return false;
243         } else if (streamReader == null) {
244             logger.debug(" connIsConnected '{}' - FALSE: streamReader=null", tivoConfigData.getCfgIdentifier());
245             return false;
246         }
247         return true;
248     }
249
250     /**
251      * {@link connTivoConnect} manages the creation / retry process of the socket connection.
252      *
253      * @return true = connected, false = not connected
254      * @throws InterruptedException
255      */
256     public boolean connTivoConnect() throws InterruptedException {
257         for (int iL = 1; iL <= tivoConfigData.getNumRetry(); iL++) {
258             logger.debug(" connTivoConnect '{}' - starting connection process '{}' of '{}'.",
259                     tivoConfigData.getCfgIdentifier(), iL, tivoConfigData.getNumRetry());
260
261             // Sort out the socket connection
262             if (connSocketConnect()) {
263                 logger.debug(" connTivoConnect '{}' - Socket created / connection made.",
264                         tivoConfigData.getCfgIdentifier());
265                 StreamReader streamReader = this.streamReader;
266                 if (streamReader != null && streamReader.isAlive()) {
267                     // Send a newline to poke the Tivo to verify it responds with INVALID_COMMAND/COMMAND_TIMEOUT
268                     streamWriter.println("\r");
269                     return true;
270                 }
271             } else {
272                 logger.debug(" connTivoConnect '{}' - Socket creation failed.", tivoConfigData.getCfgIdentifier());
273                 TiVoHandler tivoHandler = this.tivoHandler;
274                 if (tivoHandler != null) {
275                     tivoHandler.setStatusOffline();
276                 }
277             }
278             // Sleep and retry
279             doNappTime();
280         }
281         return false;
282     }
283
284     /**
285      * {@link connTivoReconnect} disconnect and reconnect the socket connection to the TiVo.
286      *
287      * @return boolean true = connection succeeded, false = connection failed
288      * @throws InterruptedException
289      */
290     public boolean connTivoReconnect() throws InterruptedException {
291         connTivoDisconnect();
292         doNappTime();
293         return connTivoConnect();
294     }
295
296     /**
297      * {@link connTivoDisconnect} cleanly closes the socket connection and dependent objects
298      *
299      */
300     public void connTivoDisconnect() throws InterruptedException {
301         TiVoHandler tivoHandler = this.tivoHandler;
302         StreamReader streamReader = this.streamReader;
303         PrintStream streamWriter = this.streamWriter;
304         Socket tivoSocket = this.tivoSocket;
305
306         logger.debug(" connTivoSocket '{}' - requested to disconnect/cleanup connection objects",
307                 tivoConfigData.getCfgIdentifier());
308
309         // if isCfgKeepConnOpen = false, don't set status to OFFLINE since the socket is closed after each command
310         if (tivoHandler != null && tivoConfigData.isKeepConnActive()) {
311             tivoHandler.setStatusOffline();
312         }
313
314         if (streamWriter != null) {
315             streamWriter.close();
316             this.streamWriter = null;
317         }
318
319         try {
320             if (tivoSocket != null) {
321                 tivoSocket.close();
322                 this.tivoSocket = null;
323             }
324         } catch (IOException e) {
325             logger.debug(" TiVo '{}' - I/O exception while disconnecting: '{}'.  Connection closed.",
326                     tivoConfigData.getCfgIdentifier(), e.getMessage());
327         }
328
329         if (streamReader != null) {
330             streamReader.interrupt();
331             streamReader.join(TIMEOUT_SEC);
332             this.streamReader = null;
333         }
334     }
335
336     /**
337      * {@link connSocketConnect} opens a Socket connection to the TiVo. Creates a {@link StreamReader} (Input)
338      * thread to read the responses from the TiVo and a PrintStream (Output) {@link cmdTivoSend}
339      * to send commands to the device.
340      *
341      * @param pConnect true = make a new connection , false = close existing connection
342      * @return boolean true = connection succeeded, false = connection failed
343      * @throws InterruptedException
344      */
345     private synchronized boolean connSocketConnect() throws InterruptedException {
346         logger.debug(" connSocketConnect '{}' - attempting connection to host '{}', port '{}'",
347                 tivoConfigData.getCfgIdentifier(), tivoConfigData.getHost(), tivoConfigData.getTcpPort());
348
349         if (connIsConnected()) {
350             logger.debug(" connSocketConnect '{}' - already connected to host '{}', port '{}'",
351                     tivoConfigData.getCfgIdentifier(), tivoConfigData.getHost(), tivoConfigData.getTcpPort());
352             return true;
353         } else {
354             // something is wrong, so force a disconnect/clean up so we can try again
355             connTivoDisconnect();
356         }
357
358         try {
359             Socket tivoSocket = new Socket(tivoConfigData.getHost(), tivoConfigData.getTcpPort());
360             tivoSocket.setKeepAlive(true);
361             tivoSocket.setSoTimeout(CONFIG_SOCKET_TIMEOUT_MS);
362             tivoSocket.setReuseAddress(true);
363
364             if (tivoSocket.isConnected() && !tivoSocket.isClosed()) {
365                 if (streamWriter == null) {
366                     streamWriter = new PrintStream(tivoSocket.getOutputStream(), false);
367                 }
368                 if (this.streamReader == null) {
369                     StreamReader streamReader = new StreamReader(tivoSocket.getInputStream());
370                     streamReader.start();
371                     this.streamReader = streamReader;
372                 }
373                 this.tivoSocket = tivoSocket;
374             } else {
375                 logger.debug(" connSocketConnect '{}' - socket creation failed to host '{}', port '{}'",
376                         tivoConfigData.getCfgIdentifier(), tivoConfigData.getHost(), tivoConfigData.getTcpPort());
377                 return false;
378             }
379
380             return true;
381
382         } catch (UnknownHostException e) {
383             logger.debug(" TiVo '{}' - while connecting, unexpected host error: '{}'",
384                     tivoConfigData.getCfgIdentifier(), e.getMessage());
385         } catch (IOException e) {
386             if (tivoStatusData.getConnectionStatus() != ConnectionStatus.OFFLINE) {
387                 logger.debug(" TiVo '{}' - I/O exception while connecting: '{}'", tivoConfigData.getCfgIdentifier(),
388                         e.getMessage());
389             }
390         }
391         return false;
392     }
393
394     /**
395      * {@link doNappTime} sleeps for the period specified by the getCmdWaitInterval parameter. Primarily used to allow
396      * the TiVo time to process responses after a command is issued.
397      *
398      * @throws InterruptedException
399      */
400     public void doNappTime() throws InterruptedException {
401         TimeUnit.MILLISECONDS.sleep(tivoConfigData.getCmdWaitInterval());
402     }
403
404     public TivoStatusData getServiceStatus() {
405         return tivoStatusData;
406     }
407
408     public void setServiceStatus(TivoStatusData tivoStatusData) {
409         this.tivoStatusData = tivoStatusData;
410     }
411
412     /**
413      * {@link StreamReader} data stream reader that reads the status data returned from the TiVo.
414      *
415      */
416     public class StreamReader extends Thread {
417         private @Nullable BufferedReader bufferedReader = null;
418
419         /**
420          * {@link StreamReader} construct a data stream reader that reads the status data returned from the TiVo via a
421          * BufferedReader.
422          *
423          * @param inputStream socket input stream.
424          * @throws IOException
425          */
426         public StreamReader(InputStream inputStream) {
427             this.setName("OH-binding-" + thingUid + "-" + tivoConfigData.getHost() + ":" + tivoConfigData.getTcpPort());
428             this.bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
429             this.setDaemon(true);
430         }
431
432         @Override
433         public void run() {
434             try {
435                 logger.debug("streamReader {} is running. ", tivoConfigData.getCfgIdentifier());
436                 while (!Thread.currentThread().isInterrupted()) {
437                     String receivedData = null;
438                     BufferedReader reader = bufferedReader;
439                     if (reader == null) {
440                         throw new IOException("streamReader failed: input stream is null");
441                     }
442
443                     try {
444                         receivedData = reader.readLine();
445                     } catch (SocketTimeoutException | SocketException e) {
446                         // Do nothing. Just allow the thread to check if it has to stop.
447                     }
448
449                     if (receivedData != null) {
450                         logger.debug("TiVo {} data received: {}", tivoConfigData.getCfgIdentifier(), receivedData);
451                         TivoStatusData commandResult = statusParse(receivedData);
452                         TiVoHandler handler = tivoHandler;
453                         if (handler != null) {
454                             handler.updateTivoStatus(tivoStatusData, commandResult);
455                         }
456                         tivoStatusData = commandResult;
457                     }
458                 }
459
460             } catch (IOException e) {
461                 closeBufferedReader();
462                 logger.debug("TiVo {} is disconnected. ", tivoConfigData.getCfgIdentifier(), e);
463             }
464             closeBufferedReader();
465             logger.debug("streamReader {} is stopped. ", tivoConfigData.getCfgIdentifier());
466         }
467
468         private void closeBufferedReader() {
469             BufferedReader reader = bufferedReader;
470             if (reader != null) {
471                 try {
472                     reader.close();
473                     this.bufferedReader = null;
474                 } catch (IOException e) {
475                     logger.debug("Error closing bufferedReader: {}", e.getMessage(), e);
476                 }
477             }
478         }
479     }
480 }