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.tivo.internal.service;
15 import static org.openhab.binding.tivo.internal.TiVoBindingConstants.CONFIG_SOCKET_TIMEOUT_MS;
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;
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;
38 * TivoStatusProvider class to maintain a connection out to the Tivo, monitor and process status messages returned..
40 * @author Jayson Kubilis - Initial contribution
41 * @author Andrew Black - Updates / compilation corrections
42 * @author Michael Lobstein - Updated for OH3
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;
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;
60 * Instantiates a new TivoConfigStatusProvider.
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.
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();
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
80 * @return {@link TivoStatusData} object
81 * @throws InterruptedException
83 public void statusRefresh() throws InterruptedException {
84 if (tivoStatusData.getConnectionStatus() != ConnectionStatus.INIT) {
85 logger.debug(" statusRefresh '{}' - EXISTING status data - '{}'", tivoConfigData.getCfgIdentifier(),
86 tivoStatusData.toString());
89 // this will close the connection and re-open every 12 hours
90 if (tivoConfigData.isKeepConnActive()) {
97 if (!tivoConfigData.isKeepConnActive()) {
103 * {@link cmdTivoSend} sends a command to the Tivo.
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
109 public @Nullable TivoStatusData cmdTivoSend(String tivoCommand) throws InterruptedException {
110 boolean connected = connTivoConnect();
111 PrintStream streamWriter = this.streamWriter;
113 if (!connected || streamWriter == null) {
114 return new TivoStatusData(false, -1, -1, false, "CONNECTION FAILED", false, ConnectionStatus.OFFLINE);
116 logger.debug("TiVo '{}' - sending command: '{}'", tivoConfigData.getCfgIdentifier(), tivoCommand);
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);
125 for (int i = 1; i <= repeatCount; i++) {
127 streamWriter.println(tivoCommand.toString() + "\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);
136 return tivoStatusData;
140 * {@link statusParse} processes the {@link TivoStatusData} status message returned from the TiVo.
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).
145 * @param rawStatus string representing the message text returned by the TiVo
146 * @return TivoStatusData object conditionally populated based upon the raw status message
148 private TivoStatusData statusParse(String rawStatus) {
149 logger.debug(" statusParse '{}' - running on string '{}'", tivoConfigData.getCfgIdentifier(), rawStatus);
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);
156 // Otherwise ignore COMMAND_TIMEOUT, they occur a few seconds after each successful command, just return
157 // existing status again
158 return this.tivoStatusData;
162 return new TivoStatusData(false, -1, -1, false, "NO_STATUS_DATA_RETURNED", false,
163 tivoStatusData.getConnectionStatus());
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);
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
187 boolean isRecording = false;
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);
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(),
202 if (rawStatus.contains("RECORDING")) {
206 rawStatus = rawStatus.replace(" REMOTE", "");
207 rawStatus = rawStatus.replace(" LOCAL", "");
208 return new TivoStatusData(true, chNum, subChNum, isRecording, rawStatus, true, ConnectionStatus.ONLINE);
210 logger.warn(" TiVo '{}' - Unhandled/unexpected status message: '{}'", tivoConfigData.getCfgIdentifier(),
212 return new TivoStatusData(false, -1, -1, false, rawStatus, false, tivoStatusData.getConnectionStatus());
216 * {@link connIsConnected} returns the connection state of the Socket, streamWriter and streamReader objects.
218 * @return true = connection exists and all objects look OK, false = connection does not exist or a problem has
222 private boolean connIsConnected() {
223 Socket tivoSocket = this.tivoSocket;
224 PrintStream streamWriter = this.streamWriter;
226 if (tivoSocket == null) {
227 logger.debug(" connIsConnected '{}' - FALSE: tivoSocket=null", tivoConfigData.getCfgIdentifier());
229 } else if (!tivoSocket.isConnected()) {
230 logger.debug(" connIsConnected '{}' - FALSE: tivoSocket.isConnected=false",
231 tivoConfigData.getCfgIdentifier());
233 } else if (tivoSocket.isClosed()) {
234 logger.debug(" connIsConnected '{}' - FALSE: tivoSocket.isClosed=true", tivoConfigData.getCfgIdentifier());
236 } else if (streamWriter == null) {
237 logger.debug(" connIsConnected '{}' - FALSE: tivoIOSendCommand=null", tivoConfigData.getCfgIdentifier());
239 } else if (streamWriter.checkError()) {
240 logger.debug(" connIsConnected '{}' - FALSE: tivoIOSendCommand.checkError()=true",
241 tivoConfigData.getCfgIdentifier());
243 } else if (streamReader == null) {
244 logger.debug(" connIsConnected '{}' - FALSE: streamReader=null", tivoConfigData.getCfgIdentifier());
251 * {@link connTivoConnect} manages the creation / retry process of the socket connection.
253 * @return true = connected, false = not connected
254 * @throws InterruptedException
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());
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");
272 logger.debug(" connTivoConnect '{}' - Socket creation failed.", tivoConfigData.getCfgIdentifier());
273 TiVoHandler tivoHandler = this.tivoHandler;
274 if (tivoHandler != null) {
275 tivoHandler.setStatusOffline();
285 * {@link connTivoReconnect} disconnect and reconnect the socket connection to the TiVo.
287 * @return boolean true = connection succeeded, false = connection failed
288 * @throws InterruptedException
290 public boolean connTivoReconnect() throws InterruptedException {
291 connTivoDisconnect();
293 return connTivoConnect();
297 * {@link connTivoDisconnect} cleanly closes the socket connection and dependent objects
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;
306 logger.debug(" connTivoSocket '{}' - requested to disconnect/cleanup connection objects",
307 tivoConfigData.getCfgIdentifier());
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();
314 if (streamWriter != null) {
315 streamWriter.close();
316 this.streamWriter = null;
320 if (tivoSocket != null) {
322 this.tivoSocket = null;
324 } catch (IOException e) {
325 logger.debug(" TiVo '{}' - I/O exception while disconnecting: '{}'. Connection closed.",
326 tivoConfigData.getCfgIdentifier(), e.getMessage());
329 if (streamReader != null) {
330 streamReader.interrupt();
331 streamReader.join(TIMEOUT_SEC);
332 this.streamReader = null;
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.
341 * @param pConnect true = make a new connection , false = close existing connection
342 * @return boolean true = connection succeeded, false = connection failed
343 * @throws InterruptedException
345 private synchronized boolean connSocketConnect() throws InterruptedException {
346 logger.debug(" connSocketConnect '{}' - attempting connection to host '{}', port '{}'",
347 tivoConfigData.getCfgIdentifier(), tivoConfigData.getHost(), tivoConfigData.getTcpPort());
349 if (connIsConnected()) {
350 logger.debug(" connSocketConnect '{}' - already connected to host '{}', port '{}'",
351 tivoConfigData.getCfgIdentifier(), tivoConfigData.getHost(), tivoConfigData.getTcpPort());
354 // something is wrong, so force a disconnect/clean up so we can try again
355 connTivoDisconnect();
359 Socket tivoSocket = new Socket(tivoConfigData.getHost(), tivoConfigData.getTcpPort());
360 tivoSocket.setKeepAlive(true);
361 tivoSocket.setSoTimeout(CONFIG_SOCKET_TIMEOUT_MS);
362 tivoSocket.setReuseAddress(true);
364 if (tivoSocket.isConnected() && !tivoSocket.isClosed()) {
365 if (streamWriter == null) {
366 streamWriter = new PrintStream(tivoSocket.getOutputStream(), false);
368 if (this.streamReader == null) {
369 StreamReader streamReader = new StreamReader(tivoSocket.getInputStream());
370 streamReader.start();
371 this.streamReader = streamReader;
373 this.tivoSocket = tivoSocket;
375 logger.debug(" connSocketConnect '{}' - socket creation failed to host '{}', port '{}'",
376 tivoConfigData.getCfgIdentifier(), tivoConfigData.getHost(), tivoConfigData.getTcpPort());
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(),
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.
398 * @throws InterruptedException
400 public void doNappTime() throws InterruptedException {
401 TimeUnit.MILLISECONDS.sleep(tivoConfigData.getCmdWaitInterval());
404 public TivoStatusData getServiceStatus() {
405 return tivoStatusData;
408 public void setServiceStatus(TivoStatusData tivoStatusData) {
409 this.tivoStatusData = tivoStatusData;
413 * {@link StreamReader} data stream reader that reads the status data returned from the TiVo.
416 public class StreamReader extends Thread {
417 private @Nullable BufferedReader bufferedReader = null;
420 * {@link StreamReader} construct a data stream reader that reads the status data returned from the TiVo via a
423 * @param inputStream socket input stream.
424 * @throws IOException
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);
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");
444 receivedData = reader.readLine();
445 } catch (SocketTimeoutException | SocketException e) {
446 // Do nothing. Just allow the thread to check if it has to stop.
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);
456 tivoStatusData = commandResult;
460 } catch (IOException e) {
461 closeBufferedReader();
462 logger.debug("TiVo {} is disconnected. ", tivoConfigData.getCfgIdentifier(), e);
464 closeBufferedReader();
465 logger.debug("streamReader {} is stopped. ", tivoConfigData.getCfgIdentifier());
468 private void closeBufferedReader() {
469 BufferedReader reader = bufferedReader;
470 if (reader != null) {
473 this.bufferedReader = null;
474 } catch (IOException e) {
475 logger.debug("Error closing bufferedReader: {}", e.getMessage(), e);