Add ohm, an OpenHome Songcast sender audio backend
Set variables for a Docker build (used by another workflow). / Set variables required for docker build. (push) Successful in 1s
Build and conditionally push docker image / docker-vars (push) Successful in 2s
Build and conditionally push docker image / Build and conditionally push docker image (SHAIRPORT_SYNC_BRANCH=.
, ./docker/classic/Dockerfile, classic) (push) Failing after 31m52s
Build and conditionally push docker image / Build and conditionally push docker image (SHAIRPORT_SYNC_BRANCH=.
NQPTP_BRANCH=${{ needs.docker-vars.outputs.nqptp_branch }}
, ./docker/Dockerfile, main) (push) Failing after 31m49s
Set variables for a Docker build (used by another workflow). / Set variables required for docker build. (push) Successful in 1s
Build and conditionally push docker image / docker-vars (push) Successful in 2s
Build and conditionally push docker image / Build and conditionally push docker image (SHAIRPORT_SYNC_BRANCH=.
, ./docker/classic/Dockerfile, classic) (push) Failing after 31m52s
Build and conditionally push docker image / Build and conditionally push docker image (SHAIRPORT_SYNC_BRANCH=.
NQPTP_BRANCH=${{ needs.docker-vars.outputs.nqptp_branch }}
, ./docker/Dockerfile, main) (push) Failing after 31m49s
Streams AirPlay audio to OpenHome/Songcast receivers as OHU unicast PCM (16/24-bit). Sends are paced on the PTP master clock so receivers slave to the AirPlay group clock through their buffer-fill servo; MediaLatency carries the phase, matching the wire behaviour of Linn's own sender (zero flags and timestamps). delay() reports the newest frame's playtime lead so shairport feeds a receiver latency ahead. Handles Join, Listen, Leave and Resend with a frame history, and forwards track metadata and metatext from the metadata hub. With -r <name>, ohm_upnp controls the receiver over UPnP: discovered by friendly name via SSDP at time of use, woken, switched to its Songcast source and pointed at this sender when a session starts; volume follows the AirPlay slider capped at the device's VolumeLimit; standby follows some minutes after the session ends.
This commit is contained in:
+579
@@ -0,0 +1,579 @@
|
||||
/*
|
||||
* OpenHome receiver control for the ohm audio backend.
|
||||
*
|
||||
* This file is part of Shairport Sync.
|
||||
* Copyright (c) Nils Schneider 2026
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include "ohm_upnp.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef TEST_OHM
|
||||
#include <stdarg.h>
|
||||
static void debug(int level, const char *fmt, ...) {
|
||||
if (level > 1)
|
||||
return;
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
fprintf(stderr, "upnp: ");
|
||||
vfprintf(stderr, fmt, ap);
|
||||
fprintf(stderr, "\n");
|
||||
va_end(ap);
|
||||
}
|
||||
#define inform(...) debug(1, __VA_ARGS__)
|
||||
#else
|
||||
#include "config.h"
|
||||
#include "common.h"
|
||||
#endif
|
||||
|
||||
#define NS_PER_SEC 1000000000LL
|
||||
#define NS_PER_MIN (60LL * NS_PER_SEC)
|
||||
|
||||
#define OHM_CTL_HTTP_MAX (128 * 1024)
|
||||
#define OHM_CTL_TIMEOUT_S 3
|
||||
#define OHM_CTL_RETRY_NS (10LL * NS_PER_SEC)
|
||||
|
||||
#define SSDP_ADDR "239.255.255.250"
|
||||
#define SSDP_PORT 1900
|
||||
#define SSDP_LOCATIONS_MAX 8
|
||||
#define SSDP_LOCATION_LEN 192
|
||||
|
||||
#define CDB_PER_DB 100 /* shairport volume unit: dB * 100 */
|
||||
#define BMDB_PER_DB 1024 /* OpenHome volume unit: binary milli-dB */
|
||||
|
||||
typedef struct {
|
||||
char type[80]; /* full urn, e.g. "urn:av-openhome-org:service:Volume:4" */
|
||||
char url[160]; /* controlURL path */
|
||||
} ohm_svc_t;
|
||||
|
||||
/* Settings, copied in ohm_upnp_configure(). */
|
||||
static struct {
|
||||
char *receiver;
|
||||
char *iface;
|
||||
char *name;
|
||||
int standby_min;
|
||||
int ohu_port;
|
||||
} set;
|
||||
|
||||
/* The discovered device, valid while .found. Tick thread only. */
|
||||
static struct {
|
||||
int found;
|
||||
char host[64];
|
||||
int port;
|
||||
ohm_svc_t receiver, volume, product;
|
||||
int vol_limit;
|
||||
int vol_step; /* binary milli-dB per volume step */
|
||||
} dev;
|
||||
|
||||
/* Requests from the backend callbacks to the tick thread. */
|
||||
static struct {
|
||||
volatile int session;
|
||||
volatile int activate;
|
||||
volatile int vol_dirty;
|
||||
volatile long vol_cdb; /* dB * 100, <= 0 */
|
||||
_Atomic uint64_t standby_at; /* 64-bit accesses must not tear on 32-bit targets */
|
||||
} req;
|
||||
|
||||
static char *scratch; /* HTTP response buffer, tick thread only */
|
||||
static uint64_t retry_at;
|
||||
|
||||
size_t ohm_xml_escape(char *dst, size_t dst_cap, const char *src) {
|
||||
size_t w = 0;
|
||||
dst[0] = '\0';
|
||||
if (!src)
|
||||
return 0;
|
||||
for (const char *s = src; *s && w + 8 < dst_cap; s++) {
|
||||
switch (*s) {
|
||||
case '&':
|
||||
memcpy(dst + w, "&", 5);
|
||||
w += 5;
|
||||
break;
|
||||
case '<':
|
||||
memcpy(dst + w, "<", 4);
|
||||
w += 4;
|
||||
break;
|
||||
case '>':
|
||||
memcpy(dst + w, ">", 4);
|
||||
w += 4;
|
||||
break;
|
||||
case '"':
|
||||
memcpy(dst + w, """, 6);
|
||||
w += 6;
|
||||
break;
|
||||
default:
|
||||
dst[w++] = *s;
|
||||
}
|
||||
}
|
||||
dst[w] = '\0';
|
||||
return w;
|
||||
}
|
||||
|
||||
static uint64_t ohm_ctl_mono_ns(void) {
|
||||
struct timespec t;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
return (uint64_t)t.tv_sec * NS_PER_SEC + (uint64_t)t.tv_nsec;
|
||||
}
|
||||
|
||||
static long ohm_ctl_lround(double d) { return (long)(d < 0 ? d - 0.5 : d + 0.5); }
|
||||
|
||||
static const char *ohm_ctl_find_ci(const char *hay, const char *needle) {
|
||||
size_t n = strlen(needle);
|
||||
for (; *hay; hay++)
|
||||
if (strncasecmp(hay, needle, n) == 0)
|
||||
return hay;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- XML scraping ---- */
|
||||
|
||||
/* Copy text from p up to the next '<' into out. */
|
||||
static int ohm_ctl_span(const char *p, char *out, size_t cap) {
|
||||
const char *e = strchr(p, '<');
|
||||
if (!e || (size_t)(e - p) >= cap)
|
||||
return -1;
|
||||
memcpy(out, p, e - p);
|
||||
out[e - p] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Copy the text content of the first <tag> into out. */
|
||||
static int ohm_ctl_tag(const char *xml, const char *tag, char *out, size_t cap) {
|
||||
char key[96];
|
||||
snprintf(key, sizeof(key), "<%s>", tag);
|
||||
const char *p = strstr(xml, key);
|
||||
return p ? ohm_ctl_span(p + strlen(key), out, cap) : -1;
|
||||
}
|
||||
|
||||
/* Fill svc from the <service> element whose serviceType starts with prefix. */
|
||||
static int ohm_ctl_service(const char *xml, const char *prefix, ohm_svc_t *svc) {
|
||||
const char *p = strstr(xml, prefix);
|
||||
if (!p || ohm_ctl_span(p, svc->type, sizeof(svc->type)) != 0)
|
||||
return -1;
|
||||
const char *end = strstr(p, "</service>");
|
||||
const char *cu = strstr(p, "<controlURL>");
|
||||
if (!cu || (end && cu > end))
|
||||
return -1;
|
||||
return ohm_ctl_span(cu + strlen("<controlURL>"), svc->url, sizeof(svc->url));
|
||||
}
|
||||
|
||||
/* ---- HTTP ---- */
|
||||
|
||||
/* Collapse chunked transfer-encoding in place; a chunk boundary must never
|
||||
* split a tag. Returns the new body length. */
|
||||
static size_t ohm_ctl_dechunk(char *body, size_t body_len) {
|
||||
char *end = body + body_len;
|
||||
char *src = body;
|
||||
char *dst = body;
|
||||
while (src < end) {
|
||||
char *num_end;
|
||||
long sz = strtol(src, &num_end, 16);
|
||||
if (num_end == src || sz <= 0)
|
||||
break;
|
||||
src = strstr(num_end, "\r\n");
|
||||
if (src == NULL)
|
||||
break;
|
||||
src += 2;
|
||||
if (src + sz > end)
|
||||
sz = (long)(end - src);
|
||||
memmove(dst, src, (size_t)sz);
|
||||
dst += sz;
|
||||
src += sz;
|
||||
if (src + 2 <= end && src[0] == '\r')
|
||||
src += 2;
|
||||
}
|
||||
*dst = '\0';
|
||||
return (size_t)(dst - body);
|
||||
}
|
||||
|
||||
/* One request/response exchange into scratch. Returns 0 on HTTP 200. */
|
||||
static int ohm_ctl_http(const char *host, int port, const char *req_buf, size_t req_len) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0)
|
||||
return -1;
|
||||
struct timeval tv = {.tv_sec = OHM_CTL_TIMEOUT_S};
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
struct sockaddr_in sin = {.sin_family = AF_INET, .sin_port = htons(port)};
|
||||
if (inet_pton(AF_INET, host, &sin.sin_addr) != 1 ||
|
||||
connect(fd, (struct sockaddr *)&sin, sizeof(sin)) != 0 ||
|
||||
send(fd, req_buf, req_len, 0) != (ssize_t)req_len) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
size_t len = 0;
|
||||
for (;;) {
|
||||
ssize_t n = recv(fd, scratch + len, OHM_CTL_HTTP_MAX - 1 - len, 0);
|
||||
if (n <= 0)
|
||||
break;
|
||||
len += (size_t)n;
|
||||
if (len >= OHM_CTL_HTTP_MAX - 1)
|
||||
break;
|
||||
}
|
||||
close(fd);
|
||||
scratch[len] = '\0';
|
||||
|
||||
const char *sp = strchr(scratch, ' ');
|
||||
if (sp == NULL || atoi(sp + 1) != 200)
|
||||
return -1;
|
||||
char *body = strstr(scratch, "\r\n\r\n");
|
||||
if (body == NULL)
|
||||
return -1;
|
||||
body += 4;
|
||||
|
||||
body[-4] = '\0'; /* terminate the headers for the search */
|
||||
int chunked = ohm_ctl_find_ci(scratch, "transfer-encoding: chunked") != NULL;
|
||||
body[-4] = '\r';
|
||||
if (chunked)
|
||||
ohm_ctl_dechunk(body, len - (size_t)(body - scratch));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int ohm_ctl_get(const char *host, int port, const char *path) {
|
||||
char req_buf[256];
|
||||
int n = snprintf(req_buf, sizeof(req_buf),
|
||||
"GET %s HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", path, host, port);
|
||||
return ohm_ctl_http(host, port, req_buf, (size_t)n);
|
||||
}
|
||||
|
||||
static int ohm_ctl_soap(const ohm_svc_t *svc, const char *action, const char *args) {
|
||||
char body[6144];
|
||||
int blen = snprintf(body, sizeof(body),
|
||||
"<?xml version=\"1.0\"?>"
|
||||
"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" "
|
||||
"s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
|
||||
"<s:Body><u:%s xmlns:u=\"%s\">%s</u:%s></s:Body></s:Envelope>",
|
||||
action, svc->type, args, action);
|
||||
char req_buf[8192];
|
||||
int rlen = snprintf(req_buf, sizeof(req_buf),
|
||||
"POST %s HTTP/1.1\r\nHost: %s:%d\r\n"
|
||||
"Content-Type: text/xml; charset=\"utf-8\"\r\n"
|
||||
"SOAPACTION: \"%s#%s\"\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
|
||||
svc->url, dev.host, dev.port, svc->type, action, blen, body);
|
||||
return ohm_ctl_http(dev.host, dev.port, req_buf, (size_t)rlen);
|
||||
}
|
||||
|
||||
/* ---- Discovery ---- */
|
||||
|
||||
/* M-SEARCH for OpenHome receivers; collect response LOCATION urls. */
|
||||
static int ohm_ctl_ssdp(char locations[][SSDP_LOCATION_LEN], int max) {
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (fd < 0)
|
||||
return 0;
|
||||
struct timeval tv = {.tv_usec = 500000};
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
if (set.iface) {
|
||||
struct in_addr mcast_if;
|
||||
if (inet_pton(AF_INET, set.iface, &mcast_if) == 1)
|
||||
setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, &mcast_if, sizeof(mcast_if));
|
||||
}
|
||||
const char *msearch = "M-SEARCH * HTTP/1.1\r\nHOST: " SSDP_ADDR ":1900\r\n"
|
||||
"MAN: \"ssdp:discover\"\r\nMX: 1\r\n"
|
||||
"ST: urn:av-openhome-org:service:Receiver:1\r\n\r\n";
|
||||
struct sockaddr_in dst = {.sin_family = AF_INET, .sin_port = htons(SSDP_PORT)};
|
||||
inet_pton(AF_INET, SSDP_ADDR, &dst.sin_addr);
|
||||
sendto(fd, msearch, strlen(msearch), 0, (struct sockaddr *)&dst, sizeof(dst));
|
||||
|
||||
int n = 0;
|
||||
char pkt[2048];
|
||||
for (int i = 0; i < max && n < max; i++) {
|
||||
ssize_t r = recv(fd, pkt, sizeof(pkt) - 1, 0);
|
||||
if (r <= 0)
|
||||
break;
|
||||
pkt[r] = '\0';
|
||||
const char *l = ohm_ctl_find_ci(pkt, "location:");
|
||||
if (!l)
|
||||
continue;
|
||||
l += strlen("location:");
|
||||
while (*l == ' ')
|
||||
l++;
|
||||
size_t len = strcspn(l, "\r\n");
|
||||
if (len == 0 || len >= SSDP_LOCATION_LEN)
|
||||
continue;
|
||||
memcpy(locations[n], l, len);
|
||||
locations[n][len] = '\0';
|
||||
n++;
|
||||
}
|
||||
close(fd);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Fetch one device description; on a friendly-name match fill dev. */
|
||||
static int ohm_ctl_probe(const char *location) {
|
||||
char host[64], path[128], fname[128];
|
||||
int port = 80;
|
||||
if (sscanf(location, "http://%63[^:/]:%d%127s", host, &port, path) != 3 &&
|
||||
sscanf(location, "http://%63[^:/]%127s", host, path) != 2)
|
||||
return -1;
|
||||
if (ohm_ctl_get(host, port, path) != 0 ||
|
||||
ohm_ctl_tag(scratch, "friendlyName", fname, sizeof(fname)) != 0 ||
|
||||
!ohm_ctl_find_ci(fname, set.receiver))
|
||||
return -1;
|
||||
if (ohm_ctl_service(scratch, "urn:av-openhome-org:service:Receiver:", &dev.receiver) != 0 ||
|
||||
ohm_ctl_service(scratch, "urn:av-openhome-org:service:Volume:", &dev.volume) != 0 ||
|
||||
ohm_ctl_service(scratch, "urn:av-openhome-org:service:Product:", &dev.product) != 0)
|
||||
return -1;
|
||||
snprintf(dev.host, sizeof(dev.host), "%s", host);
|
||||
dev.port = port;
|
||||
inform("ohm: controlling receiver \"%s\" at %s:%d", fname, dev.host, dev.port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void ohm_ctl_read_volume_caps(void) {
|
||||
dev.vol_limit = 100;
|
||||
dev.vol_step = BMDB_PER_DB;
|
||||
char v[32];
|
||||
if (ohm_ctl_soap(&dev.volume, "Characteristics", "") == 0) {
|
||||
if (ohm_ctl_tag(scratch, "VolumeMax", v, sizeof(v)) == 0)
|
||||
dev.vol_limit = atoi(v);
|
||||
if (ohm_ctl_tag(scratch, "VolumeMilliDbPerStep", v, sizeof(v)) == 0 && atoi(v) > 0)
|
||||
dev.vol_step = atoi(v);
|
||||
}
|
||||
if (ohm_ctl_soap(&dev.volume, "VolumeLimit", "") == 0 &&
|
||||
ohm_ctl_tag(scratch, "Value", v, sizeof(v)) == 0 && atoi(v) > 0)
|
||||
dev.vol_limit = atoi(v);
|
||||
debug(1, "ohm: receiver volume limit %d, %d mdB/step", dev.vol_limit, dev.vol_step);
|
||||
}
|
||||
|
||||
static int ohm_ctl_discover(void) {
|
||||
char locations[SSDP_LOCATIONS_MAX][SSDP_LOCATION_LEN];
|
||||
int n = ohm_ctl_ssdp(locations, SSDP_LOCATIONS_MAX);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (ohm_ctl_probe(locations[i]) == 0) {
|
||||
ohm_ctl_read_volume_caps();
|
||||
dev.found = 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* shairport delivers attenuation in cdB over the declared range; the device
|
||||
* runs 0..vol_limit steps. */
|
||||
static long ohm_ctl_steps_from_cdb(long cdb) {
|
||||
double step_db = (double)dev.vol_step / BMDB_PER_DB;
|
||||
long steps = dev.vol_limit + ohm_ctl_lround((double)cdb / CDB_PER_DB / step_db);
|
||||
if (steps < 0)
|
||||
steps = 0;
|
||||
if (steps > dev.vol_limit)
|
||||
steps = dev.vol_limit;
|
||||
return steps;
|
||||
}
|
||||
|
||||
/* ---- Actions ---- */
|
||||
|
||||
/* ohu:// URI of this sender as reachable from the receiver. */
|
||||
static int ohm_ctl_our_uri(char *out, size_t cap) {
|
||||
struct sockaddr_in sin = {.sin_family = AF_INET, .sin_port = htons(SSDP_PORT)};
|
||||
inet_pton(AF_INET, dev.host, &sin.sin_addr);
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (fd < 0)
|
||||
return -1;
|
||||
struct sockaddr_in local;
|
||||
socklen_t slen = sizeof(local);
|
||||
int ok = connect(fd, (struct sockaddr *)&sin, sizeof(sin)) == 0 &&
|
||||
getsockname(fd, (struct sockaddr *)&local, &slen) == 0;
|
||||
close(fd);
|
||||
if (!ok)
|
||||
return -1;
|
||||
char ip[INET_ADDRSTRLEN];
|
||||
inet_ntop(AF_INET, &local.sin_addr, ip, sizeof(ip));
|
||||
snprintf(out, cap, "ohu://%s:%d", ip, set.ohu_port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Switch the receiver's product to its Songcast source. The SourceXml body is
|
||||
* XML-escaped inside the SOAP response, so count escaped <Source> elements up
|
||||
* to the one of type Receiver. */
|
||||
static void ohm_ctl_select_source(void) {
|
||||
if (ohm_ctl_soap(&dev.product, "SourceXml", "") != 0)
|
||||
return;
|
||||
const char *rcv = strstr(scratch, "<Type>Receiver</Type>");
|
||||
if (!rcv)
|
||||
return;
|
||||
int idx = -1;
|
||||
for (const char *p = scratch; (p = strstr(p, "<Source>")) != NULL && p < rcv; p++)
|
||||
idx++;
|
||||
if (idx < 0)
|
||||
return;
|
||||
char arg[48];
|
||||
snprintf(arg, sizeof(arg), "<Value>%d</Value>", idx);
|
||||
ohm_ctl_soap(&dev.product, "SetSourceIndex", arg);
|
||||
}
|
||||
|
||||
static int ohm_ctl_set_sender(const char *uri) {
|
||||
char name[256], didl[768], meta[2304], args[2560];
|
||||
ohm_xml_escape(name, sizeof(name), set.name ? set.name : "Shairport Sync");
|
||||
snprintf(didl, sizeof(didl),
|
||||
"<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
|
||||
"xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
|
||||
"xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
|
||||
"<item id=\"0\" restricted=\"True\"><dc:title>%s</dc:title>"
|
||||
"<res protocolInfo=\"ohu:*:*:*\">%s</res>"
|
||||
"<upnp:class>object.item.audioItem</upnp:class></item></DIDL-Lite>",
|
||||
name, uri);
|
||||
ohm_xml_escape(meta, sizeof(meta), didl);
|
||||
snprintf(args, sizeof(args), "<Uri>%s</Uri><Metadata>%s</Metadata>", uri, meta);
|
||||
return ohm_ctl_soap(&dev.receiver, "SetSender", args);
|
||||
}
|
||||
|
||||
static int ohm_ctl_activate(void) {
|
||||
char uri[128];
|
||||
if (ohm_ctl_soap(&dev.product, "SetStandby", "<Value>0</Value>") != 0)
|
||||
return -1;
|
||||
ohm_ctl_select_source();
|
||||
if (ohm_ctl_our_uri(uri, sizeof(uri)) != 0 || ohm_ctl_set_sender(uri) != 0 ||
|
||||
ohm_ctl_soap(&dev.receiver, "Play", "") != 0)
|
||||
return -1;
|
||||
debug(1, "ohm: receiver pointed at %s and playing", uri);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int ohm_ctl_push_volume(void) {
|
||||
long steps = ohm_ctl_steps_from_cdb(req.vol_cdb);
|
||||
char arg[48];
|
||||
snprintf(arg, sizeof(arg), "<Value>%ld</Value>", steps);
|
||||
if (ohm_ctl_soap(&dev.volume, "SetVolume", arg) != 0)
|
||||
return -1;
|
||||
debug(2, "ohm: receiver volume %ld/%d", steps, dev.vol_limit);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Standby only if the receiver is still pointed at us. */
|
||||
static int ohm_ctl_standby(void) {
|
||||
char uri[128];
|
||||
if (ohm_ctl_soap(&dev.receiver, "Sender", "") != 0)
|
||||
return -1;
|
||||
if (ohm_ctl_our_uri(uri, sizeof(uri)) == 0 && !strstr(scratch, uri)) {
|
||||
debug(1, "ohm: receiver plays another sender, skipping standby");
|
||||
return 0;
|
||||
}
|
||||
if (ohm_ctl_soap(&dev.product, "SetStandby", "<Value>1</Value>") != 0)
|
||||
return -1;
|
||||
debug(1, "ohm: receiver put into standby");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Run every due request in order; -1 invalidates the device. */
|
||||
static int ohm_ctl_run(uint64_t now) {
|
||||
if (req.activate) {
|
||||
if (ohm_ctl_activate() != 0)
|
||||
return -1;
|
||||
req.activate = 0;
|
||||
}
|
||||
if (req.vol_dirty) {
|
||||
req.vol_dirty = 0; /* a newer volume arriving during the push re-sets it */
|
||||
if (ohm_ctl_push_volume() != 0) {
|
||||
req.vol_dirty = 1;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (req.standby_at && now >= req.standby_at) {
|
||||
if (ohm_ctl_standby() != 0)
|
||||
return -1;
|
||||
req.standby_at = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Backend interface ---- */
|
||||
|
||||
void ohm_upnp_configure(const ohm_upnp_config_t *config) {
|
||||
free(set.receiver);
|
||||
free(set.iface);
|
||||
free(set.name);
|
||||
set.receiver = config->receiver_name ? strdup(config->receiver_name) : NULL;
|
||||
set.iface = config->interface_ip ? strdup(config->interface_ip) : NULL;
|
||||
set.name = config->display_name ? strdup(config->display_name) : NULL;
|
||||
set.standby_min = config->standby_minutes;
|
||||
set.ohu_port = config->ohu_port;
|
||||
}
|
||||
|
||||
int ohm_upnp_enabled(void) { return set.receiver != NULL; }
|
||||
|
||||
void ohm_upnp_session_begin(void) {
|
||||
if (set.receiver == NULL || req.session)
|
||||
return;
|
||||
req.session = 1;
|
||||
req.standby_at = 0;
|
||||
req.activate = 1;
|
||||
}
|
||||
|
||||
void ohm_upnp_session_end(void) {
|
||||
if (set.receiver == NULL)
|
||||
return;
|
||||
req.session = 0;
|
||||
req.standby_at =
|
||||
set.standby_min > 0 ? ohm_ctl_mono_ns() + (uint64_t)set.standby_min * NS_PER_MIN : 0;
|
||||
}
|
||||
|
||||
void ohm_upnp_set_volume(long vol_cdb) {
|
||||
req.vol_cdb = vol_cdb;
|
||||
req.vol_dirty = 1;
|
||||
}
|
||||
|
||||
void ohm_upnp_tick(void) {
|
||||
if (set.receiver == NULL)
|
||||
return;
|
||||
uint64_t now = ohm_ctl_mono_ns();
|
||||
int standby_due = req.standby_at && now >= req.standby_at;
|
||||
if (!req.activate && !req.vol_dirty && !standby_due)
|
||||
return;
|
||||
if (now < retry_at)
|
||||
return;
|
||||
if (scratch == NULL && (scratch = malloc(OHM_CTL_HTTP_MAX)) == NULL)
|
||||
return;
|
||||
if (!dev.found && ohm_ctl_discover() != 0) {
|
||||
retry_at = now + OHM_CTL_RETRY_NS;
|
||||
debug(1, "ohm: receiver \"%s\" not found, retrying", set.receiver);
|
||||
return;
|
||||
}
|
||||
if (ohm_ctl_run(now) != 0) {
|
||||
dev.found = 0;
|
||||
retry_at = now + OHM_CTL_RETRY_NS;
|
||||
}
|
||||
}
|
||||
|
||||
void ohm_upnp_deinit(void) {
|
||||
free(set.receiver);
|
||||
set.receiver = NULL;
|
||||
free(set.iface);
|
||||
set.iface = NULL;
|
||||
free(set.name);
|
||||
set.name = NULL;
|
||||
free(scratch);
|
||||
scratch = NULL;
|
||||
dev.found = 0;
|
||||
req.session = 0;
|
||||
req.activate = 0;
|
||||
req.vol_dirty = 0;
|
||||
req.standby_at = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user