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