/* * Copyright (C) 2022 Ray * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ #ifdef HAVE_CONFIG_H # include #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "misc.h" #include "conffile.h" #include "logger.h" #include "player.h" #include "outputs.h" #include "mdns.h" /* RCP is the Roku Soundbridge control protocol; we can request a Soundbridge to * play data from our internal .mp3 stream and the Soundbridge will act as an * output * * References to the RCP spec are related to the Roku Functional Specification * dated 09-Aug-2007, document version 2.4 and software versions SoundBridge 3.0.44 * * RCP spec page 7 - Overview, What is RCP? * [...] the Roku Control Protocol (RCP). RCP is a control * protocol implemented by the Roku SoundBridge line of digital audio players with * software version 2.3 or later, and the Roku Wi-Fi Media Module (WMM), a drop- * in hardware solution for implementing digital audio functionality targeted for * OEMs. Remote applications can use RCP to access the digital-media * functionality of those device to automate repetitive tasks, initiate and control * media playback, or extend the user interface to other network-connected * devices. * * RCP spec page 10 - Protocol Summary * RCP was designed with simplicity and completeness as primary requirements. * Commands and results are exchanged as short transmissions across a high- * speed interface like a serial port, telnet connection, or parallel interface. Each * command is composed of a short ASCII command id string, generally just zero or * one parameters, and the two-byte terminator CRLF. All command results from * the RCP host are composed of the command-id of the client command that * caused this result followed by a result string and the two-byte CRLF terminator * * ... * * RCP commands can be loosely categorized by the way in which they execute: * synchronous commands, transacted commands, and subscription commands. * Synchronous commands return their results immediately, and do not block the * host device during execution. Transacted commands are commands that might * require a long extent of time to complete, some as long as ten seconds or more! * These commands generally depend on sending and receiving data from the * network, which is why their completion time is non-deterministic. Transacted * commands run asynchronously, or “in the background,” in an RCP session, and * allow the client to issue other commands or cancel the command while it is in * process. * * Communcations within this module only use the sync subset of commands from * the RCP spec * * RCP/Roku devices only support ipv4 */ enum rcp_state { RCP_STATE_SETUP, // 0 RCP_STATE_SETUP_WAKEUP, RCP_STATE_SETUP_GET_CONNECTED_SERVER, RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_INIT, RCP_STATE_SETUP_SERVER_DISCONNECT_DISCONNECTED, RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_END, // 5 RCP_STATE_SETUP_SERVER_DISCONNECT, RCP_STATE_SETUP_SET_SERVER_FILTER, RCP_STATE_SETUP_LIST_SERVERS_RESULT_SIZE, RCP_STATE_SETUP_LIST_SERVERS_INTERNET_RADIO, RCP_STATE_SETUP_LIST_SERVERS_RESULTS_END, // 10 RCP_STATE_SETUP_LIST_SERVERS, RCP_STATE_SETUP_SERVER_CONNECT_TRANS_INIT, RCP_STATE_SETUP_SERVER_CONNECT_CONNECTED, RCP_STATE_SETUP_SERVER_CONNECT_TRANS_END, RCP_STATE_SETUP_SERVER_CONNECT, // 15 RCP_STATE_SETUP_VOL_SET, RCP_STATE_QUEUING_CLEAR, RCP_STATE_QUEUING_SET_TITLE, RCP_STATE_QUEUING_SET_PLAYLIST_URL, RCP_STATE_QUEUING_SET_REMOTE_STREAM, // 20 RCP_STATE_QUEUING_PLAY, RCP_STATE_STREAMING, RCP_STATE_VOL_GET, RCP_STATE_VOL_SET, RCP_STATE_STOPPING, // 25 RCP_STATE_SHUTDOWN_STOPPED, RCP_STATE_SHUTDOWN_GET_CONNECTED_SERVER, RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_INIT, RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_DISCONNECTED, RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_END, // 30 RCP_STATE_SHUTDOWN_SERVER_DISCONNECT, // grouped order RCP_STATE_STANDBY, RCP_STATE_DISCONNECTED, RCP_STATE_FAILED, // 35 RCP_STATE_MAX }; struct rcp_state_map { enum rcp_state state; char *cmd; bool has_arg; }; // direct mapping to cmds against state, if applicable static const struct rcp_state_map rcp_state_send_map[] = { { RCP_STATE_SETUP, NULL }, { RCP_STATE_SETUP_WAKEUP, "SetPowerState on no" }, { RCP_STATE_SETUP_GET_CONNECTED_SERVER, "GetConnectedServer" }, { RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_INIT, "ServerDisconnect" }, { RCP_STATE_SETUP_SERVER_DISCONNECT_DISCONNECTED, NULL }, { RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_END, NULL }, { RCP_STATE_SETUP_SERVER_DISCONNECT, NULL }, { RCP_STATE_SETUP_SET_SERVER_FILTER, "SetServerFilter radio" }, { RCP_STATE_SETUP_LIST_SERVERS_RESULT_SIZE, "ListServers" }, { RCP_STATE_SETUP_LIST_SERVERS_INTERNET_RADIO, NULL }, { RCP_STATE_SETUP_LIST_SERVERS_RESULTS_END, NULL }, { RCP_STATE_SETUP_LIST_SERVERS, NULL }, { RCP_STATE_SETUP_SERVER_CONNECT_TRANS_INIT, "ServerConnect 0" }, { RCP_STATE_SETUP_SERVER_CONNECT_CONNECTED, NULL }, { RCP_STATE_SETUP_SERVER_CONNECT_TRANS_END, NULL }, { RCP_STATE_SETUP_SERVER_CONNECT, NULL }, { RCP_STATE_SETUP_VOL_SET, "SetVolume", true }, { RCP_STATE_QUEUING_CLEAR, "ClearWorkingSong" }, { RCP_STATE_QUEUING_SET_TITLE, "SetWorkingSongInfo title", true }, { RCP_STATE_QUEUING_SET_PLAYLIST_URL, "SetWorkingSongInfo playlistURL", true }, // set from session's own url { RCP_STATE_QUEUING_SET_REMOTE_STREAM, "SetWorkingSongInfo remoteStream 1" }, { RCP_STATE_QUEUING_PLAY, "QueueAndPlayOne working" }, { RCP_STATE_STREAMING, NULL }, { RCP_STATE_VOL_GET, "GetVolume" }, { RCP_STATE_VOL_SET, "SetVolume", true }, { RCP_STATE_STOPPING, NULL }, { RCP_STATE_SHUTDOWN_STOPPED, "Stop" }, { RCP_STATE_SHUTDOWN_GET_CONNECTED_SERVER, "GetConnectedServer" }, { RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_INIT, "ServerDisconnect" }, { RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_DISCONNECTED, NULL }, { RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_END, NULL }, { RCP_STATE_SHUTDOWN_SERVER_DISCONNECT, NULL }, { RCP_STATE_STANDBY, "SetPowerState standby" }, { RCP_STATE_DISCONNECTED, NULL }, { RCP_STATE_FAILED, "Reboot" }, { RCP_STATE_MAX, NULL }, }; struct rcp_session { // enum output_device_state state; enum rcp_state state; int callback_id; char *devname; char *address; unsigned short port; int sock; char *stream_url; // usues ip4 addr that the Roku believes we're on bool clear_on_close; unsigned close_timeout; // the rcp cmds are limited length - used to build response #define RCP_RESP_BUF_SIZE 256 char respbuf[RCP_RESP_BUF_SIZE+1]; // pointer into our buffer, next available location for data resp char *respptr; // 0..100 incl unsigned short volume; struct output_device *device; struct event *ev; struct event *reply_timeout; struct rcp_session *next; }; static struct rcp_session *rcp_sessions; /* From player.c */ extern struct event_base *evbase_player; // fwd static int rcp_send(struct rcp_session* s, enum rcp_state next_state, const char *cmd); /* ---------------------------- STATE MACHINE ------------------------------- */ /* Uses current state to determine valid response; returns * -1 invalid req-resp reply * 0 valid req-resp reply * 1 valid req-resp reply but failed request */ static int rcp_state_verify(struct rcp_session *s, const char *resp) { int ret; switch (s->state) { case RCP_STATE_SETUP: if (strcmp(resp, "roku: ready\r\n") == 0) return 0; goto resp_err; case RCP_STATE_SHUTDOWN_GET_CONNECTED_SERVER: case RCP_STATE_SETUP_GET_CONNECTED_SERVER: if (strcmp(resp, "GetConnectedServer: OK\r\n") == 0 || strcmp(resp, "GetConnectedServer: GenericError\r\n") == 0) return 0; goto resp_err; case RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_INIT: case RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_INIT: if (strcmp(resp, "ServerDisconnect: TransactionInitiated\r\n") == 0) { if (s->state == RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_INIT) s->state = RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_DISCONNECTED; else s->state = RCP_STATE_SETUP_SERVER_DISCONNECT_DISCONNECTED; return 0; } // roku doesnt think its connected, no other resp for this state // seen that it goes directly into ErrorDisconnected without the xact init if (strcmp(resp, "ServerDisconnect: ErrorDisconnected\r\n") == 0) { if (s->state == RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_INIT) s->state = RCP_STATE_SHUTDOWN_SERVER_DISCONNECT; else s->state = RCP_STATE_SETUP_SERVER_DISCONNECT; return 0; } if (strcmp(resp, "ServerDisconnect: ResourceAllocationError\r\n") == 0) { // this state seems like a lockup on the roku, only clearable rebooting rcp_send(s, RCP_STATE_FAILED, NULL); } if (strcmp(resp, "ServerDisconnect: GenericError\r\n") == 0) { s->state = RCP_STATE_FAILED; return -1; } goto resp_err; case RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_DISCONNECTED: case RCP_STATE_SETUP_SERVER_DISCONNECT_DISCONNECTED: if (strcmp(resp, "ServerDisconnect: Disconnected\r\n") == 0 || strcmp(resp, "ServerDisconnect: ErrorDisconnected\r\n") == 0) { if (s->state == RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_DISCONNECTED) s->state = RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_END; else s->state = RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_END; return 0; } // drop through .. reported directly xact complete after init case RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_END: case RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_END: if (strcmp(resp, "ServerDisconnect: TransactionComplete\r\n") == 0) { if (s->state == RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_END) s->state = RCP_STATE_SHUTDOWN_SERVER_DISCONNECT; else s->state = RCP_STATE_SETUP_SERVER_DISCONNECT; return 0; } goto resp_err; case RCP_STATE_SHUTDOWN_SERVER_DISCONNECT: case RCP_STATE_SETUP_SERVER_DISCONNECT: break; case RCP_STATE_SETUP_SET_SERVER_FILTER: if (strcmp(resp, "SetServerFilter: OK\r\n") == 0) return 0; goto resp_err; case RCP_STATE_SETUP_LIST_SERVERS_RESULT_SIZE: if (strcmp(resp, "ListServers: ListResultSize 1\r\n") == 0) { s->state = RCP_STATE_SETUP_LIST_SERVERS_INTERNET_RADIO; return 0; } goto resp_err; case RCP_STATE_SETUP_LIST_SERVERS_INTERNET_RADIO: if (strcmp(resp, "ListServers: Internet Radio\r\n") == 0) { s->state = RCP_STATE_SETUP_LIST_SERVERS_RESULTS_END; return 0; } goto resp_err; case RCP_STATE_SETUP_LIST_SERVERS_RESULTS_END: if (strcmp(resp, "ListServers: ListResultEnd\r\n") == 0) { s->state = RCP_STATE_SETUP_LIST_SERVERS; return 0; } goto resp_err; case RCP_STATE_SETUP_LIST_SERVERS: break; case RCP_STATE_SETUP_SERVER_CONNECT_TRANS_INIT: if (strcmp(resp, "ServerConnect: TransactionInitiated\r\n") == 0) { s->state = RCP_STATE_SETUP_SERVER_CONNECT_CONNECTED; return 0; } goto resp_err; case RCP_STATE_SETUP_SERVER_CONNECT_CONNECTED: if (strcmp(resp, "ServerConnect: Connected\r\n") == 0) { s->state = RCP_STATE_SETUP_SERVER_CONNECT_TRANS_END; return 0; } // drop through incase theres no response on this case RCP_STATE_SETUP_SERVER_CONNECT_TRANS_END: if (strcmp(resp, "ServerConnect: TransactionComplete\r\n") == 0) { s->state = RCP_STATE_SETUP_SERVER_CONNECT; return 0; } goto resp_err; case RCP_STATE_SETUP_SERVER_CONNECT: break; case RCP_STATE_SETUP_WAKEUP: case RCP_STATE_STANDBY: if (strcmp(resp, "SetPowerState: OK\r\n") == 0) return 0; goto resp_err; case RCP_STATE_QUEUING_CLEAR: if (strcmp(resp, "ClearWorkingSong: OK\r\n") == 0) return 0; goto resp_err; case RCP_STATE_QUEUING_SET_TITLE: case RCP_STATE_QUEUING_SET_PLAYLIST_URL: case RCP_STATE_QUEUING_SET_REMOTE_STREAM: if (strcmp(resp, "SetWorkingSongInfo: OK\r\n") == 0) return 0; goto resp_err; case RCP_STATE_QUEUING_PLAY: if (strcmp(resp, "QueueAndPlayOne: OK\r\n") == 0) return 0; /* this means the address we used in request to play in * RCP_STATE_QUEUING_SET_PLAYLIST_URL is invalid */ if (strcmp(resp, "QueueAndPlayOne: ParameterError\r\n") == 0) { DPRINTF(E_LOG, L_RCP, "Failed to start stream, remote unable to reach '%s' from '%s' at %s\n", s->stream_url, s->devname, s->address); return 1; } goto resp_err; case RCP_STATE_SHUTDOWN_STOPPED: if (strcmp(resp, "Stop: OK\r\n") == 0) return 0; goto resp_err; case RCP_STATE_VOL_GET: if (strncmp(resp, "GetVolume: ", strlen("GetVolume: ")) == 0) { ret = sscanf(resp, "GetVolume: %hd", &s->volume); if (ret < 0) goto resp_err; s->device->volume = s->volume; return 0; } goto resp_err; case RCP_STATE_SETUP_VOL_SET: case RCP_STATE_VOL_SET: if (strcmp(resp, "SetVolume: OK\r\n") == 0 || strcmp(resp, "SetVolume: ParameterError\r\n") == 0) return 0; goto resp_err; case RCP_STATE_STREAMING: case RCP_STATE_DISCONNECTED: case RCP_STATE_FAILED: // no resp in this state break; default: goto resp_err; } return 0; resp_err: return -1; } void rcp_session_shutdown(struct rcp_session* s, enum rcp_state state); /* Handle current state, action and move to next state * returns -1 when machine is done */ static int rcp_state_transition(struct rcp_session *s) { char buf[128]; switch (s->state) { case RCP_STATE_SETUP: rcp_send(s, RCP_STATE_SETUP_WAKEUP, NULL); break; /* RCP spec - "Usage Scenario: Testing an Internet Radio URL", page 176: * To play back an arbitrary Internet Radio URL from RCP, you must set the * “working” song to identify the URL you want to play, make sure you are * connected to an appropriate music server, and then execute the * QueueAndPlayOne command * * First, we ensure that we’re connected to the Internet Radio music server. Note * that we set the server filter to “radio” before invoking the ListServers command, * ensuring that the only list result is the built-in Internet Radio server [...] * * -> GetConnectedServer * << "GetConnectedServer: OK" * -> ServerDisconnect * << "ServerDisconnect: TransactionInitiated" * << "ServerDisconnect: Disconnected" * << "ServerDisconnect: TransactionComplete" * -> SetServerFilter radio * << "SetServerFilter: OK" * -> ListServers * << "ListServers: ListResultSize 1" * << "ListServers: Internet Radio" * << "ListServers: ListResultEnd" * -> ServerConnect 0 * << "ServerConnect: TransactionInitiated" * << "ServerConnect: Connected" * << "ServerConnect: TransactionComplete" * * -> ClearWorkingSong * << "ClearWorkingSong: OK" * -> SetWorkingSongInfo playlistURL http://ownetone.local:3689/stream.mp3 * << "SetWorkingSongInfo: OK" * -> SetWorkingSongInfo remoteStream 1 * << "SetWorkingSongInfo: OK" * -> QueueAndPlayOne working * << "QueueAndPlayOne: OK" * * alternative but ICY meta not displayed * * RCP spec - "Usage Scenario: Playing a music File on the local network", page 177: * The RCP client may wish to play back a music files stored on the * local network that is not servedby amusic server. This is possible * using the RCP session’s working song variable. The RCP client must * setup the working song’s url and format fields and then call * QueueAndPlayOne. It is recommended to not set the remoteStream * field, as this will cause the file to be played back over * automatically once it reaches the end of the file * * * Note that the RCP spec has a copy/paste error; it refers to * 'ClearWorkingSongInfo' which is an invalid command * * -> ClearWorkingSong * << "ClearWorkingSong: OK" * -> SetWorkingSongInfo title xxx * << "SetWorkingSongInfo: OK" * -> SetWorkingSongInfo url http://ownetone.local:3689/stream.mp3 * << "SetWorkingSongInfo: OK" * -> SetWorkingSongInfo format MP3 * << "SetWorkingSongInfo: OK" * -> QueueAndPlayOne working * << "QueueAndPlayOne: OK" */ case RCP_STATE_SETUP_WAKEUP: rcp_send(s, RCP_STATE_SETUP_GET_CONNECTED_SERVER, NULL); break; case RCP_STATE_SHUTDOWN_STOPPED: rcp_send(s, RCP_STATE_SHUTDOWN_GET_CONNECTED_SERVER, NULL); break; case RCP_STATE_SHUTDOWN_GET_CONNECTED_SERVER: rcp_send(s, RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_INIT, NULL); break; case RCP_STATE_SETUP_GET_CONNECTED_SERVER: rcp_send(s, RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_INIT, NULL); break; case RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_INIT: case RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_DISCONNECTED: case RCP_STATE_SHUTDOWN_SERVER_DISCONNECT_TRANS_END: case RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_INIT: case RCP_STATE_SETUP_SERVER_DISCONNECT_DISCONNECTED: case RCP_STATE_SETUP_SERVER_DISCONNECT_TRANS_END: // no response, multistage response break; case RCP_STATE_SETUP_SERVER_DISCONNECT: rcp_send(s, RCP_STATE_SETUP_SET_SERVER_FILTER, NULL); break; case RCP_STATE_SETUP_SET_SERVER_FILTER: rcp_send(s, RCP_STATE_SETUP_LIST_SERVERS_RESULT_SIZE, NULL); break; case RCP_STATE_SETUP_LIST_SERVERS_RESULT_SIZE: case RCP_STATE_SETUP_LIST_SERVERS_INTERNET_RADIO: case RCP_STATE_SETUP_LIST_SERVERS_RESULTS_END: // no response, multistage response break; case RCP_STATE_SETUP_LIST_SERVERS: rcp_send(s, RCP_STATE_SETUP_SERVER_CONNECT_TRANS_INIT, NULL); break; case RCP_STATE_SETUP_SERVER_CONNECT_TRANS_INIT: case RCP_STATE_SETUP_SERVER_CONNECT_CONNECTED: case RCP_STATE_SETUP_SERVER_CONNECT_TRANS_END: break; case RCP_STATE_SETUP_SERVER_CONNECT: snprintf(buf, sizeof(buf), "%d", s->device->volume); rcp_send(s, RCP_STATE_SETUP_VOL_SET, buf); break; case RCP_STATE_SETUP_VOL_SET: rcp_send(s, RCP_STATE_QUEUING_CLEAR, NULL); break; case RCP_STATE_QUEUING_CLEAR: rcp_send(s, RCP_STATE_QUEUING_SET_TITLE, cfg_getstr(cfg_getsec(cfg, "library"), "name")); break; case RCP_STATE_QUEUING_SET_TITLE: rcp_send(s, RCP_STATE_QUEUING_SET_PLAYLIST_URL, s->stream_url); break; case RCP_STATE_QUEUING_SET_PLAYLIST_URL: rcp_send(s, RCP_STATE_QUEUING_SET_REMOTE_STREAM, NULL); break; case RCP_STATE_QUEUING_SET_REMOTE_STREAM: rcp_send(s, RCP_STATE_QUEUING_PLAY, NULL); break; case RCP_STATE_QUEUING_PLAY: DPRINTF(E_INFO, L_RCP, "Ready '%s' volume at %d\n", s->devname, s->volume); event_del(s->reply_timeout); // fall through case RCP_STATE_VOL_GET: case RCP_STATE_VOL_SET: s->state = RCP_STATE_STREAMING; break; case RCP_STATE_STOPPING: s->state = RCP_STATE_SHUTDOWN_STOPPED; break; case RCP_STATE_STREAMING: break; case RCP_STATE_SHUTDOWN_SERVER_DISCONNECT: rcp_send(s, RCP_STATE_STANDBY, NULL); break; case RCP_STATE_STANDBY: rcp_session_shutdown(s, RCP_STATE_DISCONNECTED); goto done; break; case RCP_STATE_DISCONNECTED: goto done; break; default: DPRINTF(E_WARN, L_RCP, "Unhandled state transition %d '%s'\n", s->state, s->devname); } return 0; done: return -1; } static void rcp_status(struct rcp_session *s); // send to remote and transition to next state static int rcp_send(struct rcp_session* s, enum rcp_state next_state, const char *arg) { struct iovec iov[] = { { NULL, 0 }, // cmd { "", 0 }, // arg spacer { "", 0 }, // arg { (void*)"\r\n", 2 } }; const struct rcp_state_map *map = NULL; int ret; // ensure the state has a mapping for (int i=0; icmd == NULL)) { DPRINTF(E_WARN, L_RCP, "BUG - state machine has no cmd for state %d on '%s'\n", s->state, s->devname); return -1; } iov[0].iov_base = (void*)map->cmd; iov[0].iov_len = strlen(map->cmd); if (map->has_arg) { iov[1].iov_base = (void*)" "; iov[1].iov_len = 1; iov[2].iov_base = (void*)(arg ? arg : ""); iov[2].iov_len = arg ? strlen(arg) : 0; } // DPRINTF(E_DBG, L_RCP, "Device %" PRIu64 " state %d send '%s%s%s'\n", s->device->id, s->state, (char*)(iov[0].iov_base), (char*)(iov[1].iov_base), (char*)(iov[2].iov_base)); if (s->sock <= 0) { DPRINTF(E_LOG, L_RCP, "Ignoring send request on %s, state = %d\n", s->address, s->state); return -1; } ret = writev(s->sock, iov, 4); if (ret < 0) { s->state = RCP_STATE_FAILED; return -1; } if (ret == 0) { s->state = RCP_STATE_DISCONNECTED; return -1; } s->state = next_state; return 0; } /* Returns: * -1 - failure of some kind on link * 0 - recv'd data * * reads data */ static int rcp_recv(struct rcp_session *s) { /* The RCP responses are of finite size so we can * limit the input buf */ ssize_t recvd; const size_t avail = RCP_RESP_BUF_SIZE - (s->respptr - s->respbuf); if (avail == 0) { DPRINTF(E_WARN, L_RCP, "Protocol BUG, cmd buf (%d) exhausted %" PRIu64 " state %d\n", RCP_RESP_BUF_SIZE, s->device->id, s->state); s->state = RCP_STATE_FAILED; return -1; } recvd = read(s->sock, s->respptr, avail); // DPRINTF(E_DBG, L_RCP, "Device %" PRIu64 " state %d recv'd %zd bytes '%s'\n", s->device->id, s->state, recvd, s->respptr); if (recvd <= 0) { DPRINTF(E_LOG, L_RCP, "Failed to read response from '%s' - %s\n", s->devname, strerror(recvd == 0 ? ECONNRESET : errno)); s->state = RCP_STATE_DISCONNECTED; return -1; } s->respptr += recvd; return 0; } /* Returns non-null ptr to a single response * Roku can send multiple responses in packet */ static const char * rcp_state_1resp(char *resp, struct rcp_session *s) { char *p, *q; int len; char *ret = NULL; // find termination sequence of '\r\n' and adjust the incoming write position // s->respptr, accordingly // verify response termination sequence of '\r\n' but expected at least cmd + ':' // ie min response is 'A: OK\r\n' if (s->respptr == s->respbuf || s->respptr - s->respbuf < 6) return NULL; p = s->respbuf; q = p+1; while (q < s->respptr) { if (*p == '\r' && *q == '\n') { len = q+1 - s->respbuf; memcpy(resp, s->respbuf, len); resp[len] = '\0'; // now slide the rest of the s->respbuf to begining memmove(s->respbuf, s->respbuf+len, s->respptr - s->respbuf - len); s->respptr -= len; memset(s->respptr, 0, len); ret = &resp[0]; } ++p; ++q; if (ret) break; } return ret; } /* ---------------------------- SESSION HANDLING ---------------------------- */ void rcp_disconnect(int fd) { /* no more receptions */ shutdown(fd, SHUT_RDWR); close(fd); } void rcp_session_shutdown(struct rcp_session* s, enum rcp_state state) { event_del(s->ev); event_del(s->reply_timeout); rcp_disconnect(s->sock); s->sock = -1; DPRINTF(E_INFO, L_RCP, "Disconnected '%s'\n", s->devname); // we've shutdown, ensure state is valid switch (state) { case RCP_STATE_STANDBY ... RCP_STATE_FAILED: break; default: state = RCP_STATE_FAILED; } s->state = state; rcp_status(s); } static void rcp_reply_shutdown_timeout_cb(int fd, short what, void *arg) { struct rcp_session *s; s = (struct rcp_session *)arg; if (what != EV_TIMEOUT) { DPRINTF(E_INFO, L_RCP, "Unexpected non timeout event (%d) %s at %s\n", what, s->devname, s->address); return; } DPRINTF(E_LOG, L_RCP, "No response from '%s' (state %d), forcing shutting down\n", s->devname, s->state); rcp_session_shutdown(s, RCP_STATE_DISCONNECTED); } static void rcp_session_shutdown_init(struct rcp_session* s) { struct timeval clear_timeout = { 15, 0 }; if (s->reply_timeout) event_free(s->reply_timeout); s->reply_timeout = evtimer_new(evbase_player, rcp_reply_shutdown_timeout_cb, s); if (!s->reply_timeout) { DPRINTF(E_WARN, L_RCP, "Out of memory for shutdown reply_timeout on session\n"); rcp_session_shutdown(s, RCP_STATE_DISCONNECTED); } else { DPRINTF(E_DBG, L_RCP, "Limiting shutdown timeout %ld sec '%s' at %s\n", clear_timeout.tv_sec, s->devname, s->address); // ensure we're not blocked forever on responses event_add(s->reply_timeout, &clear_timeout); /* force the Roku into a non-library connected state, otherwise re-power * will put Roku into pre-powerdown state which will cause it to reconnect * to this server * * some users prefer non Roku connected state */ rcp_send(s, s->clear_on_close ? RCP_STATE_SHUTDOWN_GET_CONNECTED_SERVER : RCP_STATE_STANDBY, NULL); } } /* The core of this module. Libevent makes a callback to this function whenever * there is new data to be read on the fd from the Soundbridge. * Process data based on state machine */ static void rcp_listen_cb(int fd, short what, void *arg) { struct rcp_session *s; const char *p; char cmd[RCP_RESP_BUF_SIZE+1] = { 0 }; int ret; for (s = rcp_sessions; s; s = s->next) { if (s == (struct rcp_session *)arg) break; } if (!s) { DPRINTF(E_INFO, L_RCP, "Callback on dead session, ignoring\n"); return; } if (what == EV_TIMEOUT) { DPRINTF(E_LOG, L_RCP, "Unexpected timeout event on '%s', shutting down\n", s->devname); goto fail; } /* response from Soundbridge can be chunked: even simple initial response msg * can arrive a 'r' 'oku: read' 'y\r\n' * * concat the response into the cmd buffer */ ret = rcp_recv(s); if (ret < 0) { DPRINTF(E_LOG, L_RCP, "Failed to recv/construct response from '%s'\n", s->devname); goto fail; } // process all full responses in s->respbuf while ( (p = rcp_state_1resp(cmd, s)) ) { // ensure respose matches state otherwise state machine is out of whack ret = rcp_state_verify(s, cmd); if (ret < 0) goto resp_fail; if (ret > 0) goto fail; memset(cmd, 0, sizeof(cmd)); ret = rcp_state_transition(s); if (ret < 0) // all done break; rcp_status(s); } return; resp_fail: DPRINTF(E_WARN, L_RCP, "Unexpected response (parsed cmd '%s' remaining buf '%s') in state %d '%s' at %s\n", cmd, s->respbuf, s->state, s->devname, s->address); fail: // Downgrade state to make rcp_session_shutdown perform an exit which is // quick and won't require a reponse from remote s->state = RCP_STATE_FAILED; rcp_session_shutdown(s, RCP_STATE_FAILED); } static void rcp_reply_timeout_cb(int fd, short what, void *arg) { struct rcp_session *s; s = (struct rcp_session *)arg; if (what == EV_TIMEOUT && s->state != RCP_STATE_STREAMING) { DPRINTF(E_LOG, L_RCP, "Slow response from '%s' (state %d), shutting down\n", s->devname, s->state); s->state = RCP_STATE_FAILED; rcp_session_shutdown(s, RCP_STATE_FAILED); event_del(s->reply_timeout); } } /* RCP spec - "RCP Sessions" #2, page 8: * Telnet (TCP port 5555) – SoundBridge and WMM devices listen on TCP port * 5555 at their configured IPaddress for incoming connections, and expose the * RCP shell directly on this connection. Once connected, the device will * answer with the RCP initiation sequence, “roku:ready”, indicating that the * connection is ready for commands. */ static struct rcp_session * rcp_session_make(struct output_device *device, int callback_id) { struct rcp_session *s; cfg_t *cfgrcp; struct timeval rcp_resp_timeout = { 20, 0 }; int flags; struct sockaddr_storage ss = { 0 }; ev_socklen_t socklen = sizeof(ss); char addrbuf[128] = { 0 }; void *inaddr; const char *addr; int httpd_port; int ret; CHECK_NULL(L_RCP, s = calloc(1, sizeof(struct rcp_session))); s->state = RCP_STATE_SETUP; s->callback_id = callback_id; s->device = device; s->respptr = s->respbuf; cfgrcp = cfg_gettsec(cfg, "rcp", device->name); s->clear_on_close = cfgrcp ? cfg_getbool(cfgrcp, "clear_on_close") : false; s->sock = net_connect(device->v4_address, device->v4_port, SOCK_STREAM, "RCP control"); if (s->sock < 0) { DPRINTF(E_LOG, L_RCP, "Could not connect to %s\n", device->name); goto out_free_session; } ret = getsockname(s->sock, (struct sockaddr*)&ss, &socklen); if (ret < 0) { DPRINTF(E_LOG, L_RCP, "Could not determine client's connected address %s\n", device->name); goto out_close_connection; } inaddr = &((struct sockaddr_in*)&ss)->sin_addr; addr = evutil_inet_ntop(ss.ss_family, inaddr, addrbuf, sizeof(addrbuf)); if (!addr) { DPRINTF(E_LOG, L_RCP, "Could not determine client's connected address %s\n", device->name); goto out_close_connection; } httpd_port = cfg_getint(cfg_getsec(cfg, "library"), "port"); s->stream_url = safe_asprintf("http://%s:%d/stream.mp3", addr, httpd_port); s->ev = event_new(evbase_player, s->sock, EV_READ | EV_PERSIST, rcp_listen_cb, s); if (!s->ev) { DPRINTF(E_LOG, L_RCP, "Out of memory for listener event\n"); goto out_close_connection; } s->reply_timeout = evtimer_new(evbase_player, rcp_reply_timeout_cb, s); if (!s->reply_timeout) { DPRINTF(E_LOG, L_RCP, "Out of memory for reply_timeout\n"); goto out_free_ev; } flags = fcntl(s->sock, F_GETFL, 0); fcntl(s->sock, F_SETFL, flags | O_NONBLOCK); event_add(s->ev, NULL); event_add(s->reply_timeout, &rcp_resp_timeout); s->devname = strdup(device->name); s->address = strdup(device->v4_address); s->volume = device->volume; s->next = rcp_sessions; rcp_sessions = s; DPRINTF(E_DBG, L_RCP, "Make session device %" PRIu64 " %s at %s stream url '%s'\n", s->device->id, s->devname, s->address, s->stream_url); // s is now the official device session outputs_device_session_add(device->id, s); DPRINTF(E_INFO, L_RCP, "Connection to '%s' established\n", s->devname); rcp_status(s); return s; out_free_ev: event_free(s->reply_timeout); event_free(s->ev); out_close_connection: rcp_disconnect(s->sock); out_free_session: free(s); return NULL; } static void rcp_session_free(struct rcp_session *s) { if (!s) return; if (s->sock >= 0) rcp_disconnect(s->sock); if (s->ev) event_free(s->ev); if (s->reply_timeout) event_free(s->reply_timeout); free(s->stream_url); free(s->address); free(s->devname); free(s); } static void rcp_session_cleanup(struct rcp_session *rs) { struct rcp_session *s; if (!rs) return; if (rs == rcp_sessions) rcp_sessions = rcp_sessions->next; else { for (s = rcp_sessions; s && (s->next != rs); s = s->next) ; /* EMPTY */ if (!s) { DPRINTF(E_WARN, L_RCP, "WARNING: struct rcp_session (%s at %s) not found in list; BUG!\n", rs->devname, rs->address); return; } s->next = rs->next; } outputs_device_session_remove(rs->device->id); rcp_session_free(rs); } /* ---------------------------- STATUS HANDLERS ----------------------------- */ static void rcp_status(struct rcp_session *s) { enum output_device_state state; switch (s->state) { case RCP_STATE_SETUP: case RCP_STATE_SETUP_WAKEUP: state = OUTPUT_STATE_STARTUP; break; case RCP_STATE_SETUP_GET_CONNECTED_SERVER ... RCP_STATE_SETUP_VOL_SET: case RCP_STATE_QUEUING_CLEAR ... RCP_STATE_QUEUING_PLAY: case RCP_STATE_VOL_GET: case RCP_STATE_VOL_SET: case RCP_STATE_STOPPING ... RCP_STATE_STANDBY: state = OUTPUT_STATE_CONNECTED; break; case RCP_STATE_STREAMING: state = OUTPUT_STATE_STREAMING; break; case RCP_STATE_DISCONNECTED: state = OUTPUT_STATE_STOPPED; break; case RCP_STATE_FAILED: default: state = OUTPUT_STATE_FAILED; } DPRINTF(E_DBG, L_RCP, "Mapping state from (internal) %d -> (output) %d\n", s->state, state); outputs_cb(s->callback_id, s->device->id, state); s->callback_id = -1; if (state == OUTPUT_STATE_STOPPED || state == OUTPUT_STATE_FAILED) rcp_session_cleanup(s); } /* ------------------ INTERFACE FUNCTIONS CALLED BY OUTPUTS.C --------------- */ static int rcp_device_start(struct output_device *device, int callback_id) { struct rcp_session *s; s = rcp_session_make(device, callback_id); if (!s) return -1; return 1; } static int rcp_device_stop(struct output_device *device, int callback_id) { struct rcp_session *s = device->session; /* force these devices as deselected (auto state saves in db later) since * these need use to select (and cause the device probe to start connection to * remote side */ s->callback_id = callback_id; // tear this session down, incl free'ing it rcp_session_shutdown_init(s); return 1; } static int rcp_device_flush(struct output_device *device, int callback_id) { struct rcp_session *s = device->session; s->callback_id = callback_id; rcp_status(s); return 1; } static int rcp_device_probe(struct output_device *device, int callback_id) { struct rcp_session *s; s = rcp_session_make(device, callback_id); if (!s) return -1; return 1; } static int rcp_device_volume_set(struct output_device *device, int callback_id) { struct rcp_session *s = device->session; char cmd[4]; int ret; if (s->state != RCP_STATE_STREAMING) return 0; s->callback_id = callback_id; ret = snprintf(cmd, sizeof(cmd), "%d", device->volume); if (ret < 0) { return 0; } rcp_send(s, RCP_STATE_VOL_SET, cmd); return 1; } static void rcp_device_cb_set(struct output_device *device, int callback_id) { struct rcp_session *s = device->session; s->callback_id = callback_id; } static void rcp_mdns_device_cb(const char *name, const char *type, const char *domain, const char *hostname, int family, const char *address, int port, struct keyval *txt) { struct output_device *device; bool exclude; int ret; /* $ avahi-browse -vrt _roku-rcp._tcp Server version: avahi 0.7; Host name: foo.local E Ifce Prot Name Type Domain + eth0 IPv4 SoundBridge _roku-rcp._tcp local = eth0 IPv4 SoundBridge _roku-rcp._tcp local hostname = [SoundBridge.local] address = [192.168.0.3] port = [5555] txt = [] : Cache exhausted : All for now */ exclude = cfg_getbool(cfg_gettsec(cfg, "rcp", name), "exclude"); DPRINTF(E_DBG, L_RCP, "Event for %sRCP/SoundBridge device '%s' (address %s, port %d)\n", exclude ? "excluded " : "", name, address, port); if (exclude) { DPRINTF(E_INFO, L_RCP, "Excluding discovered RCP/SoundBridge device '%s' at %s\n", name, address); return; } CHECK_NULL(L_RCP, device = calloc(1, sizeof(struct output_device))); device->id = djb_hash(name, strlen(name)); device->name = strdup(name); device->type = OUTPUT_TYPE_RCP; device->type_name = outputs_name(device->type); if (port < 0 || !address) { ret = player_device_remove(device); } else { // RCP/Roku Soundbridges only support ipv4 device->v4_address = strdup(address); device->v4_port = port; DPRINTF(E_INFO, L_RCP, "Adding RCP output device '%s' at '%s'\n", name, address); ret = player_device_add(device); } if (ret < 0) outputs_device_free(device); } static int rcp_init(void) { cfg_t *cfg_rcp; int i; int ret; // validate best we can rcp_state_send_map has all the rcp_states assert(ARRAY_SIZE(rcp_state_send_map) == RCP_STATE_MAX+1); //DPRINTF(E_FATAL, L_RCP, "BUG: rcp_state_send_map[] (%d) out of sync with rcp_states (%d)\n", ARRAY_SIZE(rcp_state_send_map), RCP_STATE_MAX); for (i=0; i