/* * 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 #include #include #include #include #include #include #include #include #include #include #ifdef TEST_OHM #include 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 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 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, ""); const char *cu = strstr(p, ""); if (!cu || (end && cu > end)) return -1; return ohm_ctl_span(cu + strlen(""), 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), "" "" "%s", 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 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), "%d", 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), "" "%s" "%s" "object.item.audioItem", name, uri); ohm_xml_escape(meta, sizeof(meta), didl); snprintf(args, sizeof(args), "%s%s", 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", "0") != 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), "%ld", 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", "1") != 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; }