1377 lines
38 KiB
C
Raw Normal View History

/*
* Copyright (C) 2022 Ray <whatdoineed2do@gmail.com>
*
* 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 <config.h>
#endif
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <net/if.h>
#include <netinet/in.h>
#include <ifaddrs.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdint.h>
#include <inttypes.h>
#include <assert.h>
#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 were 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 sessions working song variable. The RCP client must
* setup the working songs 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; i<RCP_STATE_MAX; ++i)
{
if (rcp_state_send_map[i].state == next_state &&
rcp_state_send_map[i].cmd)
{
map = &rcp_state_send_map[i];
break;
}
}
if (!map || (map && map->cmd == 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<RCP_STATE_MAX; i++)
{
assert(rcp_state_send_map[i].state == (enum rcp_state)i);
//DPRINTF(E_FATAL, L_RCP, "BUG: rcp_state_send_map[%d] out of sync with enum rcp_states\n", i);
}
cfg_rcp = cfg_gettsec(cfg, "rcp", "*");
if (cfg_rcp && cfg_getbool(cfg_rcp, "exclude"))
{
DPRINTF(E_LOG, L_RCP, "Excluding all RCP/SoundBridges\n");
return 0;
}
ret = mdns_browse("_roku-rcp._tcp", rcp_mdns_device_cb, 0);
if (ret < 0)
{
DPRINTF(E_LOG, L_RCP, "Could not add mDNS browser for RCP/SoundBridge devices\n");
return -1;
}
return 0;
}
static void
rcp_deinit(void)
{
struct rcp_session *s;
for (s = rcp_sessions; rcp_sessions; s = rcp_sessions)
rcp_session_cleanup(s);
}
struct output_definition output_rcp =
{
.name = "RCP/SoundBridge",
.type = OUTPUT_TYPE_RCP,
.priority = 99,
.disabled = 0,
.init = rcp_init,
.deinit = rcp_deinit,
.device_start = rcp_device_start,
.device_stop = rcp_device_stop,
.device_flush = rcp_device_flush,
.device_probe = rcp_device_probe,
.device_volume_set = rcp_device_volume_set,
.device_cb_set = rcp_device_cb_set,
};