Compare commits
1
Commits
master
..
73264f4534
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73264f4534 |
+1
-1
@@ -113,7 +113,7 @@ shairport_sync_SOURCES += audio_dummy.c
|
||||
endif
|
||||
|
||||
if USE_OHM
|
||||
shairport_sync_SOURCES += audio_ohm.c ohm_upnp.c
|
||||
shairport_sync_SOURCES += audio_ohm.c
|
||||
endif
|
||||
|
||||
if USE_AO
|
||||
|
||||
+83
-150
@@ -36,8 +36,6 @@
|
||||
#include "config.h"
|
||||
#endif
|
||||
|
||||
#include "ohm_upnp.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <ifaddrs.h>
|
||||
@@ -248,14 +246,11 @@ static struct {
|
||||
char *name; /* sender display name, default "Shairport Sync" */
|
||||
int max_receivers;
|
||||
int history_size;
|
||||
char *receiver_name; /* UPnP-control the receiver with this friendly name */
|
||||
int standby_min;
|
||||
} cfg = {
|
||||
.port = OHM_DEFAULT_PORT,
|
||||
.latency_ms = OHM_DEFAULT_LATENCY_MS,
|
||||
.max_receivers = OHM_DEFAULT_MAX_RECEIVERS,
|
||||
.history_size = OHM_DEFAULT_HISTORY,
|
||||
.standby_min = 15,
|
||||
};
|
||||
|
||||
/* Socket and thread lifecycle. */
|
||||
@@ -267,9 +262,6 @@ static struct {
|
||||
pthread_t sender_thread;
|
||||
int sender_running;
|
||||
int sender_created;
|
||||
pthread_t upnp_thread;
|
||||
int upnp_running;
|
||||
int upnp_created;
|
||||
int watcher_registered;
|
||||
} rt = {
|
||||
.sock = -1,
|
||||
@@ -459,11 +451,6 @@ static size_t ohm_build_audio(uint8_t *buf, uint32_t samples, uint32_t frame, ui
|
||||
static size_t ohm_build_track(uint8_t *buf, uint32_t seq, const char *uri, const char *metadata) {
|
||||
size_t uri_len = uri ? strlen(uri) : 0;
|
||||
size_t md_len = metadata ? strlen(metadata) : 0;
|
||||
size_t cap = OHM_MAX_MSG - OHM_HEADER_BYTES - 12;
|
||||
if (uri_len > cap)
|
||||
uri_len = cap;
|
||||
if (md_len > cap - uri_len)
|
||||
md_len = cap - uri_len;
|
||||
size_t total = OHM_HEADER_BYTES + 12 + uri_len + md_len;
|
||||
ohm_write_header(buf, OHM_MSG_TRACK, (uint16_t)total);
|
||||
uint8_t *p = buf + OHM_HEADER_BYTES;
|
||||
@@ -479,9 +466,6 @@ static size_t ohm_build_track(uint8_t *buf, uint32_t seq, const char *uri, const
|
||||
|
||||
static size_t ohm_build_metatext(uint8_t *buf, uint32_t seq, const char *text) {
|
||||
size_t t_len = text ? strlen(text) : 0;
|
||||
size_t cap = OHM_MAX_MSG - OHM_HEADER_BYTES - 8;
|
||||
if (t_len > cap)
|
||||
t_len = cap;
|
||||
size_t total = OHM_HEADER_BYTES + 8 + t_len;
|
||||
ohm_write_header(buf, OHM_MSG_METATEXT, (uint16_t)total);
|
||||
uint8_t *p = buf + OHM_HEADER_BYTES;
|
||||
@@ -499,6 +483,40 @@ static size_t ohm_build_control(uint8_t *buf, uint8_t type) {
|
||||
|
||||
/* ---- DIDL-Lite / metatext from the metadata hub ---- */
|
||||
|
||||
/* XML-escape src into dst (NUL-terminated). Returns chars written (excluding NUL). */
|
||||
static size_t ohm_xml_escape(char *dst, size_t dst_cap, const char *src) {
|
||||
size_t w = 0;
|
||||
if (dst == NULL || dst_cap == 0)
|
||||
return 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;
|
||||
}
|
||||
|
||||
/* Build a DIDL-Lite metadata string from the current track. Caller frees. */
|
||||
static char *ohm_build_didl_lite_locked(void) {
|
||||
/* Called under the metadata hub write lock (from the watcher) or not at all.
|
||||
@@ -592,8 +610,6 @@ static void ohm_send_metatext_to_all_locked(void) {
|
||||
/* ---- Metadata watcher (runs under the hub write lock) ---- */
|
||||
|
||||
static void ohm_metadata_watcher(struct metadata_bundle *md, __attribute__((unused)) void *userdata) {
|
||||
if (!rt.control_running)
|
||||
return;
|
||||
/* Decide whether this is a track-identity change worth re-sending. */
|
||||
int track_changed = md->track_name_changed || md->artist_name_changed || md->album_name_changed ||
|
||||
md->item_id_changed || md->songtime_in_milliseconds_changed ||
|
||||
@@ -621,7 +637,6 @@ static void ohm_metadata_watcher(struct metadata_bundle *md, __attribute__((unus
|
||||
pthread_mutex_unlock(&control.lock);
|
||||
}
|
||||
|
||||
|
||||
/* ---- Control thread: Join / Listen / Leave / Resend + expiry ---- */
|
||||
|
||||
static void ohm_handle_resend_locked(const uint8_t *payload, size_t payload_len,
|
||||
@@ -740,19 +755,6 @@ static void *ohm_control_thread(__attribute__((unused)) void *arg) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* UPnP receiver control runs on its own thread: discovery and SOAP block for
|
||||
* seconds and must never stall Join/Listen/Resend handling. */
|
||||
#define OHM_UPNP_TICK_MS 250
|
||||
|
||||
static void *ohm_upnp_thread(__attribute__((unused)) void *arg) {
|
||||
const struct timespec interval = {.tv_nsec = OHM_UPNP_TICK_MS * 1000000L};
|
||||
while (rt.upnp_running) {
|
||||
ohm_upnp_tick();
|
||||
nanosleep(&interval, NULL);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- Paced sender thread ---- */
|
||||
|
||||
static void ohm_audio_unlock_cleanup(__attribute__((unused)) void *arg) {
|
||||
@@ -1067,14 +1069,6 @@ static int ohm_start_socket(void) {
|
||||
setsockopt(rt.sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
|
||||
ohm_print_uris(bound_port);
|
||||
ohm_upnp_config_t upnp = {
|
||||
.receiver_name = cfg.receiver_name,
|
||||
.interface_ip = cfg.interface_ip,
|
||||
.display_name = cfg.name,
|
||||
.standby_minutes = cfg.standby_min,
|
||||
.ohu_port = bound_port,
|
||||
};
|
||||
ohm_upnp_configure(&upnp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1089,8 +1083,6 @@ static void help(void) {
|
||||
printf(" -m <n> max simultaneous receivers (default %d)\n",
|
||||
OHM_DEFAULT_MAX_RECEIVERS);
|
||||
printf(" -H <n> resend history frames (default %d)\n", OHM_DEFAULT_HISTORY);
|
||||
printf(" -r <name> control the receiver with this friendly name (UPnP)\n");
|
||||
printf(" -S <min> minutes after stop before receiver standby (default 15, 0 = never)\n");
|
||||
}
|
||||
|
||||
static int init(__attribute__((unused)) int argc, __attribute__((unused)) char **argv) {
|
||||
@@ -1101,9 +1093,43 @@ static int init(__attribute__((unused)) int argc, __attribute__((unused)) char *
|
||||
cfg.history_size = OHM_DEFAULT_HISTORY;
|
||||
cfg.name = strdup("Shairport Sync");
|
||||
|
||||
/* command line (shairport passes this backend's arguments as a slice) */
|
||||
optind = 1; /* optind=0 is equivalent to optind=1 plus special behaviour */
|
||||
argv--; /* shift so getopt() sees a conventional argv[0..argc-1] */
|
||||
argc++;
|
||||
opterr = 0;
|
||||
int opt;
|
||||
while ((opt = getopt(argc, argv, "o:i:l:n:m:H:")) > 0) {
|
||||
switch (opt) {
|
||||
case 'o':
|
||||
cfg.port = atoi(optarg);
|
||||
break;
|
||||
case 'i':
|
||||
free(cfg.interface_ip);
|
||||
cfg.interface_ip = strdup(optarg);
|
||||
break;
|
||||
case 'l':
|
||||
cfg.latency_ms = atoi(optarg);
|
||||
break;
|
||||
case 'n':
|
||||
free(cfg.name);
|
||||
cfg.name = strdup(optarg);
|
||||
break;
|
||||
case 'm':
|
||||
cfg.max_receivers = atoi(optarg);
|
||||
break;
|
||||
case 'H':
|
||||
cfg.history_size = atoi(optarg);
|
||||
break;
|
||||
default:
|
||||
warn("ohm: invalid audio option \"-%c\" specified -- ignored.", optopt);
|
||||
help();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* settings file (libconfig), stanza "ohm". config.cfg may be NULL if no
|
||||
* configuration file was supplied / readable, so guard every lookup.
|
||||
* Command-line options are parsed afterwards and take priority. */
|
||||
* configuration file was supplied / readable, so guard every lookup. */
|
||||
if (config.cfg != NULL) {
|
||||
const char *str;
|
||||
int value;
|
||||
@@ -1129,55 +1155,6 @@ static int init(__attribute__((unused)) int argc, __attribute__((unused)) char *
|
||||
if (config_lookup_int(config.cfg, "ohm.history", &value)) {
|
||||
cfg.history_size = value;
|
||||
}
|
||||
if (config_lookup_non_empty_string(config.cfg, "ohm.receiver", &str)) {
|
||||
free(cfg.receiver_name);
|
||||
cfg.receiver_name = strdup(str);
|
||||
}
|
||||
if (config_lookup_int(config.cfg, "ohm.standby_minutes", &value)) {
|
||||
cfg.standby_min = value;
|
||||
}
|
||||
}
|
||||
|
||||
/* command line (shairport passes this backend's arguments as a slice) */
|
||||
optind = 1; /* optind=0 is equivalent to optind=1 plus special behaviour */
|
||||
argv--; /* shift so getopt() sees a conventional argv[0..argc-1] */
|
||||
argc++;
|
||||
opterr = 0;
|
||||
int opt;
|
||||
while ((opt = getopt(argc, argv, "o:i:l:n:m:H:r:S:")) > 0) {
|
||||
switch (opt) {
|
||||
case 'o':
|
||||
cfg.port = atoi(optarg);
|
||||
break;
|
||||
case 'i':
|
||||
free(cfg.interface_ip);
|
||||
cfg.interface_ip = strdup(optarg);
|
||||
break;
|
||||
case 'l':
|
||||
cfg.latency_ms = atoi(optarg);
|
||||
break;
|
||||
case 'n':
|
||||
free(cfg.name);
|
||||
cfg.name = strdup(optarg);
|
||||
break;
|
||||
case 'm':
|
||||
cfg.max_receivers = atoi(optarg);
|
||||
break;
|
||||
case 'H':
|
||||
cfg.history_size = atoi(optarg);
|
||||
break;
|
||||
case 'r':
|
||||
free(cfg.receiver_name);
|
||||
cfg.receiver_name = strdup(optarg);
|
||||
break;
|
||||
case 'S':
|
||||
cfg.standby_min = atoi(optarg);
|
||||
break;
|
||||
default:
|
||||
warn("ohm: invalid audio option \"-%c\" specified -- ignored.", optopt);
|
||||
help();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg.max_receivers < 1)
|
||||
@@ -1185,13 +1162,12 @@ static int init(__attribute__((unused)) int argc, __attribute__((unused)) char *
|
||||
if (cfg.history_size < 0)
|
||||
cfg.history_size = OHM_DEFAULT_HISTORY;
|
||||
|
||||
if (cfg.latency_ms < 1) {
|
||||
warn("ohm: latency %dms is invalid, using %dms", cfg.latency_ms, OHM_DEFAULT_LATENCY_MS);
|
||||
cfg.latency_ms = OHM_DEFAULT_LATENCY_MS;
|
||||
} else if (cfg.latency_ms < 100) {
|
||||
warn("ohm: latency %dms is below the working range of typical receivers; expect no sync",
|
||||
cfg.latency_ms);
|
||||
}
|
||||
if (cfg.latency_ms < 100)
|
||||
warn("ohm: latency %dms is below the working range of typical receivers; expect no sync", cfg.latency_ms);
|
||||
|
||||
/* delay() never reports less than the receiver latency, so the feed gate
|
||||
* must sit above it; the margin is the ring's working level. */
|
||||
config.audio_backend_buffer_desired_length += cfg.latency_ms / 1000.0;
|
||||
|
||||
#ifndef CONFIG_AIRPLAY_2
|
||||
parse_audio_options("ohm", (1 << SPS_FORMAT_S16_BE) | (1 << SPS_FORMAT_S24_3BE),
|
||||
@@ -1201,12 +1177,6 @@ static int init(__attribute__((unused)) int argc, __attribute__((unused)) char *
|
||||
(1 << SPS_RATE_48000), (1 << 2));
|
||||
#endif
|
||||
|
||||
/* delay() never reports less than the receiver latency, so the feed gate
|
||||
* must sit above it; the margin is the ring's working level.
|
||||
* parse_audio_options() may have replaced the value, so add the latency
|
||||
* afterwards. */
|
||||
config.audio_backend_buffer_desired_length += cfg.latency_ms / 1000.0;
|
||||
|
||||
control.receivers = calloc(cfg.max_receivers, sizeof(ohm_receiver_t));
|
||||
control.history = calloc(cfg.history_size > 0 ? cfg.history_size : 1, sizeof(ohm_history_entry_t));
|
||||
control.bcast_addrs = calloc(cfg.max_receivers, sizeof(struct sockaddr_storage));
|
||||
@@ -1256,25 +1226,12 @@ static int init(__attribute__((unused)) int argc, __attribute__((unused)) char *
|
||||
return -1;
|
||||
}
|
||||
rt.sender_created = 1;
|
||||
|
||||
if (ohm_upnp_enabled()) {
|
||||
rt.upnp_running = 1;
|
||||
if (pthread_create(&rt.upnp_thread, NULL, ohm_upnp_thread, NULL) != 0) {
|
||||
warn("ohm: cannot create upnp thread: %s.", strerror(errno));
|
||||
rt.upnp_running = 0;
|
||||
return -1;
|
||||
}
|
||||
rt.upnp_created = 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void deinit(void) {
|
||||
/* Cooperative shutdown: never pthread_cancel, a thread cancelled inside a
|
||||
* lock-held region would die holding the lock. */
|
||||
rt.sender_running = 0;
|
||||
rt.control_running = 0;
|
||||
rt.upnp_running = 0;
|
||||
pthread_mutex_lock(&audio.lock);
|
||||
pthread_cond_broadcast(&audio.cond);
|
||||
pthread_cond_broadcast(&audio.not_full);
|
||||
@@ -1283,29 +1240,15 @@ static void deinit(void) {
|
||||
shutdown(rt.sock, SHUT_RDWR);
|
||||
}
|
||||
if (rt.sender_created) {
|
||||
pthread_cancel(rt.sender_thread);
|
||||
pthread_join(rt.sender_thread, NULL);
|
||||
rt.sender_created = 0;
|
||||
}
|
||||
if (rt.control_created) {
|
||||
pthread_cancel(rt.control_thread);
|
||||
pthread_join(rt.control_thread, NULL);
|
||||
rt.control_created = 0;
|
||||
}
|
||||
if (rt.upnp_created) {
|
||||
pthread_join(rt.upnp_thread, NULL);
|
||||
rt.upnp_created = 0;
|
||||
}
|
||||
|
||||
/* The metadata hub has no unregister; the watcher bails out on
|
||||
* !control_running. Cycling the hub write lock waits out any watcher that
|
||||
* passed that check before the flag was cleared, so the frees below cannot
|
||||
* race it. */
|
||||
#ifndef TEST_OHM
|
||||
if (rt.watcher_registered) {
|
||||
metadata_hub_modify_prolog();
|
||||
metadata_hub_modify_epilog(0);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (rt.sock >= 0) {
|
||||
close(rt.sock);
|
||||
rt.sock = -1;
|
||||
@@ -1328,9 +1271,6 @@ static void deinit(void) {
|
||||
cfg.interface_ip = NULL;
|
||||
free(cfg.name);
|
||||
cfg.name = NULL;
|
||||
free(cfg.receiver_name);
|
||||
cfg.receiver_name = NULL;
|
||||
ohm_upnp_deinit();
|
||||
}
|
||||
|
||||
static int prepare(void) {
|
||||
@@ -1422,8 +1362,6 @@ static int play(void *buf, int samples, int sample_type,
|
||||
if (!audio.configured || samples <= 0)
|
||||
return samples;
|
||||
|
||||
ohm_upnp_session_begin();
|
||||
|
||||
uint64_t wallclock = ohm_now_ns();
|
||||
if (sample_type == play_samples_are_untimed)
|
||||
debug(3, "ohm: play samples=%d untimed wall=%llums", samples,
|
||||
@@ -1485,10 +1423,7 @@ static void ohm_request_halt(void) {
|
||||
pthread_mutex_unlock(&audio.lock);
|
||||
}
|
||||
|
||||
static void stop(void) {
|
||||
ohm_request_halt();
|
||||
ohm_upnp_session_end();
|
||||
}
|
||||
static void stop(void) { ohm_request_halt(); }
|
||||
|
||||
static void flush(void) { ohm_request_halt(); }
|
||||
|
||||
@@ -1501,6 +1436,8 @@ static int is_running(void) {
|
||||
* L), so the report is simply its playtime lead. Without timing marks fall
|
||||
* back to occupancy plus the receiver latency. */
|
||||
static int ohm_delay(long *the_delay) {
|
||||
if (the_delay == NULL)
|
||||
return -1;
|
||||
pthread_mutex_lock(&audio.lock);
|
||||
if (!audio.configured || audio.fmt.sample_rate == 0) {
|
||||
pthread_mutex_unlock(&audio.lock);
|
||||
@@ -1529,14 +1466,10 @@ static int ohm_delay(long *the_delay) {
|
||||
}
|
||||
|
||||
static void volume(double vol) {
|
||||
if (ohm_upnp_enabled())
|
||||
ohm_upnp_set_volume((long)vol);
|
||||
else
|
||||
debug(2, "ohm: volume %.2f ignored, receiver controls volume.", vol);
|
||||
debug(2, "ohm: volume %.2f ignored, receiver controls volume.", vol);
|
||||
}
|
||||
|
||||
static volume_range_t ohm_volume_range = {.minimum_volume_dB = -OHM_UPNP_VOLUME_RANGE_CDB,
|
||||
.maximum_volume_dB = 0};
|
||||
static volume_range_t ohm_volume_range = {.minimum_volume_dB = -9630, .maximum_volume_dB = 0};
|
||||
static output_parameters_t ohm_parameters = {.volume_range = &ohm_volume_range};
|
||||
|
||||
static output_parameters_t *parameters(void) { return &ohm_parameters; }
|
||||
|
||||
@@ -1948,9 +1948,6 @@ char *get_version_string() {
|
||||
#ifdef CONFIG_PIPE
|
||||
strcat(version_string, "-pipe");
|
||||
#endif
|
||||
#ifdef CONFIG_OHM
|
||||
strcat(version_string, "-ohm");
|
||||
#endif
|
||||
#ifdef CONFIG_SOXR
|
||||
strcat(version_string, "-soxr");
|
||||
#endif
|
||||
|
||||
@@ -60,53 +60,7 @@ RUN ../configure --sysconfdir=/etc --with-alsa \
|
||||
--with-dbus-interface --with-mpris-interface \
|
||||
--with-convolution --with-soxr
|
||||
RUN make -j"$(nproc)"
|
||||
RUN make install DESTDIR=/install
|
||||
|
||||
# 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
|
||||
|
||||
# nqptp sidecar image for AirPlay 2 timing; shares /dev/shm with the
|
||||
# shairport-sync container (see k8s/shairport-ohm.yaml).
|
||||
FROM --platform=linux/arm64 debian:${DEBIAN_VERSION} AS nqptp-build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential autoconf automake libtool git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth=1 https://github.com/mikebrady/nqptp /nqptp
|
||||
WORKDIR /nqptp
|
||||
RUN autoreconf -i && ./configure && make
|
||||
|
||||
FROM --platform=linux/arm64 debian:${DEBIAN_VERSION}-slim AS nqptp
|
||||
COPY --from=nqptp-build /nqptp/nqptp /usr/local/bin/nqptp
|
||||
ENTRYPOINT ["/usr/local/bin/nqptp"]
|
||||
|
||||
# Deployable runtime image. Uses the node's avahi (host D-Bus socket mounted)
|
||||
# and the nqptp sidecar (shared /dev/shm); see k8s/shairport-ohm.yaml.
|
||||
#
|
||||
# docker buildx build --platform linux/arm64 --target runtime \
|
||||
# -t <registry>/shairport-sync-ohm:latest -f docker/Dockerfile.ohm --push .
|
||||
# docker buildx build --platform linux/arm64 --target nqptp \
|
||||
# -t <registry>/nqptp:latest -f docker/Dockerfile.ohm --push .
|
||||
FROM --platform=linux/arm64 debian:${DEBIAN_VERSION}-slim AS runtime
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libasound2 \
|
||||
libavahi-client3 \
|
||||
libavcodec58 \
|
||||
libavformat58 \
|
||||
libavutil56 \
|
||||
libconfig9 \
|
||||
libgcrypt20 \
|
||||
libglib2.0-0 \
|
||||
libplist3 \
|
||||
libpopt0 \
|
||||
libsndfile1 \
|
||||
libsodium23 \
|
||||
libsoxr0 \
|
||||
libssl1.1 \
|
||||
libswresample3 \
|
||||
libuuid1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /install/usr/local/bin/shairport-sync /usr/local/bin/shairport-sync
|
||||
ENTRYPOINT ["/usr/local/bin/shairport-sync"]
|
||||
|
||||
-579
@@ -1,579 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
Reference in New Issue
Block a user