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

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:
2026-08-28 23:54:16 +02:00
parent fadd43d06e
commit b75edeedc3
9 changed files with 2284 additions and 2 deletions
+3
View File
@@ -2,3 +2,6 @@
documents
docker/Dockerfile
docker/classic/Dockerfile
reference
ohm.txt
out-ohm
+4
View File
@@ -112,6 +112,10 @@ if USE_DUMMY
shairport_sync_SOURCES += audio_dummy.c
endif
if USE_OHM
shairport_sync_SOURCES += audio_ohm.c ohm_upnp.c
endif
if USE_AO
shairport_sync_SOURCES += audio_ao.c
endif
+6
View File
@@ -62,6 +62,9 @@ extern audio_output audio_pipe;
#ifdef CONFIG_STDOUT
extern audio_output audio_stdout;
#endif
#ifdef CONFIG_OHM
extern audio_output audio_ohm;
#endif
static audio_output *outputs[] = {
#ifdef CONFIG_ALSA
@@ -93,6 +96,9 @@ static audio_output *outputs[] = {
#endif
#ifdef CONFIG_DUMMY
&audio_dummy,
#endif
#ifdef CONFIG_OHM
&audio_ohm,
#endif
NULL};
+1560
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -98,6 +98,12 @@ if test "x$with_pipe" = "xyes" ; then
fi
AM_CONDITIONAL([USE_PIPE], [test "x$with_pipe" = "xyes" ])
AC_ARG_WITH([ohm],[AS_HELP_STRING([--with-ohm],[build the OpenHome Songcast OHM/OHU unicast sender audio backend into Shairport Sync])])
if test "x$with_ohm" = "xyes" ; then
AC_DEFINE([CONFIG_OHM], 1, [Include the OpenHome Songcast OHM/OHU unicast sender audio backend.])
fi
AM_CONDITIONAL([USE_OHM], [test "x$with_ohm" = "xyes"])
# Check to see if we should include the System V initscript
AC_ARG_WITH([systemv-startup],[AS_HELP_STRING([--with-systemv-startup],[install a System V startup script during a make install])])
+66
View File
@@ -0,0 +1,66 @@
# Minimal cross-build of shairport-sync with the OHM Songcast sender backend,
# for linux/arm64 (Raspberry Pi OS aarch64). Produces a glibc binary compatible
# with Debian bullseye (glibc 2.31) and later (e.g. bookworm).
#
# Usage from the repo root:
# docker buildx build --platform linux/arm64 --target binary \
# -f docker/Dockerfile.ohm --output type=local,dest=./out-ohm .
#
# Then copy ./out-ohm/shairport-sync to the Pi.
#
# FFmpeg is taken from Debian packages (libavcodec-dev etc.) rather than built
# from source, to keep the cross-build fast under emulation.
ARG DEBIAN_VERSION=bullseye
FROM --platform=linux/arm64 debian:${DEBIAN_VERSION} AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
autoconf \
automake \
libtool \
pkg-config \
git \
ca-certificates \
xxd \
libasound2-dev \
libplist-dev \
libplist-utils \
libssl-dev \
libsodium-dev \
libgcrypt-dev \
uuid-dev \
libavcodec-dev \
libavformat-dev \
libswresample-dev \
libavutil-dev \
libavahi-client-dev \
avahi-daemon \
libdbus-1-dev \
libglib2.0-dev \
libconfig-dev \
libpopt-dev \
libsndfile-dev \
libsoxr-dev \
xmltoman \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /shairport-sync
COPY . .
WORKDIR /shairport-sync/build
RUN autoreconf -i ../
RUN ../configure --sysconfdir=/etc --with-alsa \
--with-avahi --with-ssl=openssl \
--with-airplay-2 --with-ffmpeg --with-metadata \
--with-ohm --with-dummy --with-pipe --with-stdout \
--with-dbus-interface --with-mpris-interface \
--with-convolution --with-soxr
RUN make -j"$(nproc)"
# Target that yields just the binary on the host filesystem via --output.
FROM --platform=linux/arm64 scratch AS binary
COPY --from=builder /shairport-sync/build/shairport-sync /shairport-sync
+1 -1
View File
@@ -4,7 +4,7 @@
#include "rtsp.h"
#include <pthread.h>
#define number_of_watchers 2
#define number_of_watchers 3
typedef enum {
PS_NOT_AVAILABLE = 0,
+579
View File
@@ -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, "&amp;", 5);
w += 5;
break;
case '<':
memcpy(dst + w, "&lt;", 4);
w += 4;
break;
case '>':
memcpy(dst + w, "&gt;", 4);
w += 4;
break;
case '"':
memcpy(dst + w, "&quot;", 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, "&lt;Type&gt;Receiver&lt;/Type&gt;");
if (!rcv)
return;
int idx = -1;
for (const char *p = scratch; (p = strstr(p, "&lt;Source&gt;")) != 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;
}
+58
View File
@@ -0,0 +1,58 @@
/*
* 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.
*/
#pragma once
#include <stddef.h>
/* XML-escape src into dst (NUL-terminated). Returns chars written (excluding NUL). */
size_t ohm_xml_escape(char *dst, size_t dst_cap, const char *src);
/* The backend declares volume as attenuation -OHM_UPNP_VOLUME_RANGE_CDB..0
* (dB * 100); shairport stretches the AirPlay slider over exactly this range. */
#define OHM_UPNP_VOLUME_RANGE_CDB 6000
typedef struct {
const char *receiver_name; /* friendly name to match; NULL disables the module */
const char *interface_ip;
const char *display_name;
int standby_minutes; /* 0 = never */
int ohu_port;
} ohm_upnp_config_t;
/* Strings are copied. */
void ohm_upnp_configure(const ohm_upnp_config_t *config);
int ohm_upnp_enabled(void);
/* Callback side: cheap, thread-safe, never block. */
void ohm_upnp_session_begin(void);
void ohm_upnp_session_end(void);
void ohm_upnp_set_volume(long vol_cdb); /* dB * 100, <= 0 */
/* Does the network work (SSDP discovery, SOAP); call periodically from one
* thread. */
void ohm_upnp_tick(void);
void ohm_upnp_deinit(void);