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