AirConnect

AirConnect

apps.detail.types.app from i.Flip721's Repository

apps.detail.sections.overview

AirPlay to UPnP/Sonos and Chromecast, in a single container, with a web interface for seeing what is actually happening. Host networking is required, not a preference. AirPlay discovery is mDNS (5353/udp multicast), UPnP discovery is SSDP (1900/udp multicast), and the bridges bind ephemeral RTSP/RTP/HTTP ports per device unless AIRCONNECT_PORT_BASE is set. None of that survives a bridge network, and the Diagnostics page fails explicitly if bridge mode is detected. Two host-side gotchas: if avahi holds 5353 exclusively, set disallow-other-stacks=no in /etc/avahi/avahi-daemon.conf; and IGMP snooping on your switch will silently filter discovery.

AirConnect

AirPlay to UPnP/Sonos and Chromecast, in a single container, with a web interface for seeing what is actually happening.

Built on philippe44/AirConnect 1.10.1 (MIT), with 12 patches for the reliability and Sonos problems documented below, and a Rust supervisor that makes the bridges observable.

Release Info | Docker Image

AirConnect Docker Hub

docker run -d --name airconnect --network host \
  -v ./config:/config \
  -e AIRCONNECT_CODEC=mp3:320 \
  -e AIRCONNECT_HTTP_LENGTH=-1 \
  -e AIRCONNECT_STREAM_TYPE=radio \
  iflip721/airconnect:latest

Then open http://your-host:8080.


Why this exists

Three problems, all confirmed by reading the upstream source rather than guessing from symptoms.

Sessions took 10–15 seconds to start

UPnP renderers wait for a Content-Length that a live AirPlay stream can never provide. Telling Sonos the stream is endless (x-rincon-mp3radio://) rather than lying about its size takes startup to 564 ms, measured on S2 firmware 17.2.6.

The widely-recommended alternative — advertise a fixed fake length with -g 0 — starts just as fast (689 ms) but kills the session 30–50 seconds in on S2, so this distribution does not use it.

Note also that upstream's README recommends -l 1000:2000 for Sonos, and the most popular AirConnect container bakes it in. That value makes the delay worse — it adds two seconds of silence buffering on top of the renderer's own, as a permanent latency floor.

Sessions sometimes never started, or took minutes

Three separate bugs, each of which permanently disables a device while the process still looks healthy — which is why restarting "fixes" it, and why no other AirConnect image's health check notices:

  • a remote-control connect() with no timeout, made while holding the device mutex, stalling for the kernel's full SYN-retry budget (over two minutes on Linux) when the controlling phone slept;
  • a sender that vanished without TEARDOWN holding the RTSP session forever, so the speaker appeared in the picker but could never be played to again;
  • an encoder malloc'd with codec, buffer and data uninitialised, so teardown freed uninitialised heap — on exactly the path a failing session takes.

Older Sonos speakers were unreliable

They were advertised a stream 1 millisecond long (any non-zero duration puts the DIDL on the musicTrack branch instead of audioBroadcast), volume reads always failed because of an inverted if in avt_util.c, and speakers randomly failed to appear because master election matched coordinators from unrelated zone groups. Being order-dependent, that last one changed on every restart.

Also, the transferMode.dlna.org header that 1.10.0's changelog advertises was written into the request structure instead of the response, so it never reached any device.

And nothing was observable

Upstream has no management interface: no status endpoint, no metrics, no state signals. The only introspection is an interactive stdin console — and the documented way to run non-interactively (-Z) makes the process call pause() and never read stdin, disabling even that. Without -Z it uses scanf, which returns immediately forever on EOF and pegs a CPU core.

So upstream offers a choice between a CPU spin and no control channel, and every existing AirConnect container takes -Z. This one patches the loop to idle in pause() on EOF, runs without -Z, and holds the pipe open. Everything the web UI does is built on that.


Installing

docker compose (recommended)

docker-compose.yml in this repo ships the measured baseline with the reasoning inline. Copy it and run:

docker compose up -d

It sets AIRCONNECT_WEB_PORT: 8282, so the UI is on http://your-host:8282. Change that one line if you want the 8080 default.

docker run

The command at the top of this file. Without those three -e flags you get the sonos-fast preset, which starts fast and then dies on Sonos S2.

Available images

iflip721/airconnect, published 2026-08-03:

Tag Contents
latest, 1.0.0 Current release
0.2.0 Previous release
0.1.0 Older

Each tag is a manifest list covering linux/amd64, linux/arm64 and linux/arm/v7 — about 36 MB compressed, 165 MB on disk. A Raspberry Pi 4/5 (arm64) is a first-class target and is what the measurements in this document were taken on.

Upgrading

docker compose pull && docker compose up -d

Nothing ever self-updates. AIRCONNECT_UPDATE_CHECK only queries the upstream GitHub release tag and reports it in the UI; no code is downloaded or replaced inside a running container.


Docker details

Image layout

Base debian:bookworm-slim
Entrypoint /usr/local/bin/airconnect-manager, running as PID 1
Binaries airupnp, aircast (patched), airconnect-manager
Volume /config
Exposed 8080/tcp (documentation only — host networking bypasses it)
Healthcheck curl -fsS http://127.0.0.1:$AIRCONNECT_WEB_PORT/healthz, 30s interval, 20s start period, 3 retries
Runtime packages libssl3, ca-certificates, curl, tzdata

The base must be glibc. The vendored dependency archives are crosstool-NG glibc objects and will not link against musl, so Alpine is not an option.

libssl3 is a hard requirement rather than an optimisation: the bridges dlopen() libssl.so.3 at runtime through crosstools' forwarding symbols and exit if it is missing.

The manager runs as PID 1 deliberately. It owns the child bridges, holds their stdin control pipes open, reaps them, and translates SIGTERM into an orderly shutdown. That shutdown goes through the console exit command before signals, because the bridges' signal handler is not async-signal-safe — it joins threads, frees, and does I/O from signal context. The manager allows 8s for that; docker-compose.yml sets stop_grace_period: 15s to stay outside it.

If the manager dies, audio is unaffected: it supervises and observes, and never touches the audio path.

The healthcheck actually checks something

/healthz returns 503 when any configured bridge is not running. Combined with the restart supervision (exponential backoff to 60s, reset after a bridge has survived 60s), that catches the crash-loop and wedged cases that no other AirConnect image detects at all.

If you override the healthcheck in your own compose file, keep the port expansion. A compose-level healthcheck replaces the image's outright, so a hardcoded 8080 makes the container permanently unhealthy the moment AIRCONNECT_WEB_PORT changes.

Volumes

/config holds airupnp.xml and aircast.xml. Mount it to keep discovered devices across upgrades. The directory must be writable — a read-only mount also crashes upstream's config writer, which does not check whether the file opened. The Diagnostics page probes for this.


Networking

--network host is mandatory.

AirPlay discovery is mDNS multicast (5353/udp), UPnP discovery is SSDP multicast (1900/udp), and the bridges bind ephemeral RTSP/RTP/HTTP ports per device unless you pin a range. None of that survives a bridge network. The Diagnostics page detects bridge mode and says so rather than leaving you with an empty device list.

Two other environmental failures, both checked automatically:

  • avahi on the host may hold 5353 exclusively. Set disallow-other-stacks=no in /etc/avahi/avahi-daemon.conf. The bridges bind with SO_REUSEADDR/SO_REUSEPORT, so this is not always fatal.
  • IGMP snooping on a switch or router will silently filter discovery.

On a multi-homed host set AIRCONNECT_BIND_IP, or discovery may bind the wrong interface.


Configuration

Everything is environment variables. Upstream supports none — there is not a single getenv in its tree — so the manager translates them into command-line flags.

The shipped baseline

docker-compose.yml does not use a preset unmodified. It takes sonos-fast for its latency and overrides the rest — the only one of four configurations measured on Sonos S2 that both starts quickly and stays up:

AIRCONNECT_CODEC: "mp3:320"      # FLAC (~900 kbit/s) outruns a weak wireless link
AIRCONNECT_HTTP_LENGTH: "-1"     # NOT 0 — that means INT_MAX, and stalls S2
AIRCONNECT_STREAM_TYPE: radio    # requires a lossy codec, or it plays nothing

The evidence, four sessions against one S2 coordinator on firmware 17.2.6, each played until it failed or was clearly stable:

Codec -g -S Time to audio Result
flac 0 (INT_MAX) broadcast 689 ms dies at 30.7 s
flac -3 (chunked) broadcast 1826 ms dies at 47.5 s
flac -1 (none) radio never plays at all
mp3:320 -1 (none) radio 564 ms stable

Two conclusions, only one of them expected. Framing is not sufficient — removing the length entirely did not save the FLAC stream, it only bought 17 more seconds. The dominant variable is bitrate: delivery settled at 67% of real time, so the sustainable ceiling sits near 600 kbit/s, and FLAC out of this encoder runs about 900.

That ceiling is a property of the link, not the firmware. The failing coordinator was wireless with 2.06% packet loss; a second speaker on the same firmware and network reported zero drops. Check a speaker's link before blaming the bridge:

curl -s http://<sonos-ip>:1400/status/ifconfig | grep -A3 -E 'br0|eth0'

eth0 at 0 packets means the speaker is wireless, and br0's dropped counter gives the quality directly.

Presets

AIRCONNECT_PRESET Flags it implies When
sonos-fast (default) -g 0 -l 50:100 Firmware where you have verified a long session survives — not S2
compat -c wav -g 0 -l 50:100 Firmware that refuses FLAC. WAV's ~4 GB header caps a session at 3–6 hours
upstream none Reproducing stock upstream behaviour

Aliases: fast/sonos_fast, wav, stock/default. Any unrecognised value is treated as custom and implies nothing, leaving every setting to the variables below.

The built-in default stays sonos-fast so a bare docker run reproduces the historical behaviour — which means a bare docker run will show a Diagnostics warning about -g 0. Use the compose file, or set the three variables above.

Variables

Explicit variables always override whatever the preset implied.

Core

Variable Default Notes
AIRCONNECT_MODE both both, upnp, cast
AIRCONNECT_PRESET sonos-fast See above
AIRCONNECT_CONFIG_DIR /config Mount a volume here to keep settings
AIRCONNECT_WEB_PORT 8080 Taken on the host directly, under host networking
AIRCONNECT_WEB_BIND 0.0.0.0
AIRCONNECT_UPDATE_CHECK true Notify only — nothing ever self-updates
TZ UTC Not read by AirConnect — glibc uses it to render local timestamps

Audio and timing

Variable Default Notes
AIRCONNECT_CODEC preset flac[:0-9][/1152-16384], wav, pcm, mp3[:rate], aac[:rate]. Must be lossy if STREAM_TYPE=radio
AIRCONNECT_HTTP_LENGTH preset -3 chunked, -1 none, 0 fixed = INT_MAX, not "no length". airupnp only
AIRCONNECT_STREAM_TYPE binary default (broadcast) Sonos only: broadcast, track, radio. airupnp only
AIRCONNECT_LATENCY preset rtp:http[:f] in ms
AIRCONNECT_FLUSH true false passes --noflush
AIRCONNECT_DRIFT false true passes -r

AIRCONNECT_STREAM_TYPE selects how a Sonos is told to present the stream, and only affects Sonos — every other renderer always gets audioBroadcast:

Value DIDL URI
broadcast (default) audioBroadcast, no res@duration http://
track musicTrack, res@duration="0:00:00.001" http://
radio audioBroadcast, no res@duration x-rincon-mp3radio://

radio requires a lossy codec. This build lifts upstream's mp3/aac gate on the scheme, and the patch comment claims it is a generic endless-stream scheme. On S2 firmware that is false: a FLAC payload over it is fetched continuously and never played — the speaker re-requests the stream every ~19 seconds indefinitely while /api/sessions still reports a healthy session serving audio. Upstream's gate was load-bearing. This is the only completely silent failure in the product, so the Diagnostics page fails the combination explicitly.

Network

Variable Default Notes
AIRCONNECT_BIND_IP auto Set on multi-homed hosts. AIRCONNECT_BIND_INTERFACE is accepted as an alias
AIRCONNECT_UPNP_PORT auto Must be ≥ 49152. Appended to the airupnp binding
AIRCONNECT_PORT_BASE ephemeral Pins the RTSP/RTP/HTTP pool
AIRCONNECT_PORT_RANGE Size ≥ (devices × 4) + 1

Device selection and naming

Variable Applies to Flag
AIRCONNECT_NAME_FORMAT both -N
AIRCONNECT_EXCLUDE_MODELS airupnp -m
AIRCONNECT_EXCLUDE_NAMES airupnp -n
AIRCONNECT_INCLUDE_MODELS airupnp -o
AIRCONNECT_MEDIA_VOLUME aircast -v

Logging and escape hatches

Variable Notes
AIRCONNECT_LOG_{MAIN,UTIL,RAOP,UPNP,CAST} errorsdebug. Starting values only — the UI changes them live. Components are filtered per bridge, so CAST is never passed to airupnp
AIRCONNECT_LOG The manager's own tracing filter (info by default), unrelated to the bridges
AIRCONNECT_{UPNP,CAST}_EXTRA_ARGS Raw flags appended verbatim. Simple double quoting is honoured, so -N "Living Room %s" survives

-Z is never passed, and passing it yourself via the extra-args escape hatch would disable the bridges' console and with it every live feature of the web interface — session detail, log streaming and runtime log levels.

Per-device configuration

The bridges resolve precedence as per-device XML > command line > <common> XML, so per-device overrides belong in config.xml rather than in flags.

Not yet implemented. The bridges are started with -x /config/<bridge>.xml but nothing ever writes that file — upstream only saves it with -i/-I, neither of which is passed — so the file never exists and every setting above is currently global. This also puts real levers out of reach: <metadata> gates the ICY injection that splits a send at every 16 KB boundary, and there is no command-line flag for it.


The web interface

Five views, served from the manager binary with no external assets.

Sessions is the reason this exists. Every AirPlay handoff is drawn as the chain it actually is:

connected ──▶ streaming ──▶ told what ──▶ told play ──▶ audio out
   RTSP        RECORD     SetAVTransportURI   Play    renderer GET

with the elapsed milliseconds at each step. time_to_audio_ms is measured from RECORD, not from the RTSP connection, because that is the interval users experience as "the delay". When a session stalls on the way up, the phase it stopped at identifies the cause, and the page names the remedy instead of showing a gap.

Its limit is worth stating plainly: every rule is about starting, none about staying up. A session that reaches "audio out" and then degrades or dies is reported as healthy, and so is one that serves bytes the speaker never plays. Diagnosing those still means reading the log's drain … gap: trend by hand.

Devices — everything discovered, its transport state, error count, and whether it is wedged behind a blocked call (the bridge reports whether the device mutex was held when sampled; persistently set is the signature).

Logs — live over WebSocket, filterable by severity, subsystem and substring, and verbosity is changeable on a running bridge. Turning on RAOP debug to diagnose a session no longer means killing the session you are diagnosing. The buffer holds the last 5,000 records; session history holds the last 100.

Diagnostics — five checks on the environment the bridges depend on but cannot report themselves: host vs bridge networking, SSDP multicast group membership, port 5353 conflicts, config writability, and whether the startup tuning is coherent. One click exports a bundle (config, logs, sessions, checks, redacted environment) suitable for handing to a maintainer.

Overview — bridge state, counts, and any startup notices.


API

The UI is one client of a documented JSON API — anything it does is scriptable.

Endpoint Purpose
GET /healthz Liveness; 503 when a configured bridge is down
GET /api/status Bridge state, argv, counts, notices
GET /api/devices Discovered renderers, with a decoded state_name
GET /api/sessions Session timelines and diagnoses, newest first
GET /api/logs Filter by level, component, q, since, limit (default 500, max 5000)
GET /api/diagnostics Environment checks
GET /api/config Resolved config, including the exact argv each bridge was given
GET /api/update Upstream release tag; notify-only
GET /api/bundle Everything above in one downloadable file
POST /api/bridges/:bridge/loglevel {"component":"raop","level":"debug"}
POST /api/bridges/:bridge/rescan Re-poll device status now
WS /api/events Live logs and change notifications

:bridge is airupnp/upnp or aircast/cast. Anything matching PASS, TOKEN, SECRET or KEY is redacted from /api/config and /api/bundle, which are meant to be shared with strangers.

curl -s localhost:8080/api/sessions | jq '.sessions[0].phases'
curl -s -XPOST localhost:8080/api/bridges/airupnp/loglevel \
     -H 'content-type: application/json' -d '{"component":"raop","level":"debug"}'

Building

docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
  -f docker/Dockerfile -t iflip721/airconnect:latest .

Upstream is fetched at pinned SHAs and patched at build time — nothing is vendored into the working tree, because a recursive clone is ~2.7 GB including a checked-in 93 MB release zip. Fetching only the submodules actually compiled against brings that to ~175 MB. If those repositories are unreachable, the build falls back to a checksummed snapshot published on this repo's releases; see Pins. Every stage cross-compiles from $BUILDPLATFORM rather than emulating, so a three-arch build does not take three times as long, and Rust never runs under qemu.

Five stages: platform (maps TARGETPLATFORM onto Debian's multiarch name, the GNU triple, upstream's targets/ name and the Rust triple), web (stages the UI into dist/), cbuild (fetch, patch, cross-compile the bridges), rustbuild (cross-compile the manager with the UI embedded), and the runtime image.

Working on the bridges

scripts/fetch-upstream.sh        fetch pinned sources into build/upstream
scripts/apply-patches.sh         apply patches/series, failing loudly on drift
scripts/build-bridges.sh         rebuild patched objects, build both bridges
scripts/refresh-patches.sh       regenerate patches/ after re-rolling
scripts/make-upstream-snapshot.sh  build the source snapshot for a new pin

patches/series is grouped by target repository, because AirConnect's submodules are separate git repos — a patch to common/libraop must be applied inside it, not at the top level. Patches apply with git apply --3way, so an upstream bump that breaks one produces a conflict report rather than a bare "does not apply".

Two build details worth knowing before changing anything:

  • libraop and libcodecs are shipped prebuilt. Rather than rebuild those archives, build-bridges.sh recompiles only the three translation units we patch (raop_server.c, raop_streamer.c, encoder.c) and splices them in with ar r. Rebuilding libraop wholesale would drag in the curve25519 submodule that we deliberately do not fetch and the bridges never reference.
  • There is a licence gate. libcodecs.a transitively references libmad and libfaad, both GPL-2.0. The bridges only ever encode, and decode ALAC via libraop's own alac.c, so no member of either should be linked — but the build asserts it with nm and fails rather than publishing an image that would have to be offered under the GPL.

Manager only

The manager is a normal Cargo project in manager/, with unit tests covering argv construction, the tuning checks, session-timeline folding and the log parser:

cd manager && cargo test

AIRCONNECT_WEB_DIST points rust-embed at the built UI; the Dockerfile sets it to the staged web-dist. The web build is deliberately dependency-free — it copies web/src to web/dist and nothing else — so there is no bundler supply chain in the image.

Releasing

Publishing is the Publish Docker image workflow, run manually from the Actions tab. It cross-compiles, pushes to Docker Hub with the DOCKER_HUB_KEY repository secret, tags the commit and writes the release notes.

It takes one tag field, split on the first -:

Input Tags pushed
1.0.0-latest 1.0.0 and latest
1.0.0-dev 1.0.0 and dev
1.0.0 1.0.0 only
dev dev only

The version is whatever you type. It is not checked against manager/Cargo.toml and does not have to look like a version at all — the only constraint is that each half be a tag Docker will accept — so a new version can be cut whenever you want one, with or without bumping the tree first.

A -dev run republishes the version tag too, since that is what the delimiter rule says; dispatch a bare dev to push a test image without touching a version tag. Every run tags the commit v<version> and publishes a GitHub Release whose environment-variable section is extracted from the env-vars markers in this file; untick push_git_tag or create_release for a build that should leave no trace beyond the image. A second tag of exactly dev marks the release as a prerelease.

The platforms input defaults to linux/amd64,linux/arm64. Pass linux/amd64,linux/arm64,linux/arm/v7 to keep the manifest three-wide, as the Available images table above describes — that table is hand-maintained and does not update itself.

.github/scripts/check-env-docs.sh --strict diffs the variables the manager reads against the ones documented here; the workflow runs it non-blocking.

Publish upstream snapshot is the other manual workflow, and it runs on a different clock — once per upstream.lock change, not per image release. See Pins.

Pins

upstream.lock pins upstream at tag 1.10.1 (42b933b) plus eight submodule SHAs. common/libopenssl (677 MB) is skipped because only the -static link variant needs it, and common/libpthreads4w is Windows-only. Bumping a pin is a deliberate, reviewable change.

Git SHAs are content-addressed, so the pins already guarantee what gets built: a force-push, a retagged release or a compromised account cannot hand the build different bytes than the patches were written against. What they cannot guarantee is that those repositories still exist.

So the same tree is also published as a release asset here, and fetch-upstream.sh falls back to it when the clone fails:

AIRCONNECT_SOURCE=git       # clone only; an upstream outage fails the build
AIRCONNECT_SOURCE=snapshot  # snapshot only; how the workflow verifies itself
AIRCONNECT_SOURCE=auto      # default — clone, then snapshot if that fails

The fallback is not a weaker path. AIRCONNECT_SNAPSHOT_SHA256 in upstream.lock is checked before anything is unpacked, and an empty hash disables the fallback rather than trusting an unverified download — an availability backstop never becomes a way in for bytes the pins did not authorise.

The snapshot is a verbatim copy of the pinned tree, ~51 MB compressed, minus only what the build already discards: upstream's 93 MB prebuilt release zip and the 80 MB of cliraop executables under common/libraop/bin. Both are pruned on the clone path too, so the two sources produce identical trees.

Storing just the files patches/ touches would not have worked. The patches cover ~15 files, but build-bridges.sh recompiles three translation units and splices them with ar r into prebuilt archives it never builds — the tree is the unit that has to survive, not the patched files.

Rebuild it after any pin change with the Publish upstream snapshot workflow, which tags a release after the pin rather than an image version (upstream-1.10.1-42b933b59523), verifies the published asset by fetching and patching through it, and only then commits the hash back to upstream.lock. scripts/make-upstream-snapshot.sh does the same thing locally and prints the two lines to record.


Licence

MIT, as upstream. Third-party components are listed in THIRD-PARTY-NOTICES.md; the build asserts that no GPL-only decoder objects are linked into the published binaries.

Upstream AirConnect is the work of philippe44, and this is a packaging and patching layer on top of it. Bugs found here are reported upstream where they apply.

apps.marketingCta.appInstallTitle

apps.marketingCta.appInstallDescription

apps.installHelp.stepOpen apps.installHelp.stepSearchApp apps.installHelp.stepReview apps.installHelp.stepInstall

apps.detail.sections.categories

apps.downloadStats.title

411
apps.downloadStats.metrics.totalDownloads

apps.detail.sections.related

apps.detail.sections.details

apps.detail.details.repository
iflip721/airconnect:latest
apps.detail.details.lastUpdated2026-08-04
apps.detail.details.firstSeen2026-08-05

apps.detail.sections.runtime

apps.detail.details.webui
http://[IP]:8282
apps.detail.details.network
host
apps.detail.details.shell
sh
apps.detail.details.privileged
false
apps.detail.details.extraParams
--stop-timeout=15

apps.detail.sections.configuration

Config PathPathrw

Holds airupnp.xml and aircast.xml, so discovered devices survive an upgrade. The directory must be writable — a read-only mount crashes upstream's config writer.

apps.detail.config.target
/config
apps.detail.config.value
/mnt/cache/appdata/airconnect/config
AIRCONNECT_MODEVariable

Which bridges to run. Options: both (default) | upnp (alias airupnp) | cast (alias aircast). Any unrecognised value falls back to both.

apps.detail.config.value
both
AIRCONNECT_PRESETVariable

Tuning preset. Options: sonos-fast (default, implies -g 0 -l 50:100; aliases fast, sonos_fast) | compat (adds -c wav for firmware that refuses FLAC, but WAV caps a session at roughly 3-6 hours; alias wav) | upstream (stock upstream behaviour, no overrides; aliases stock, default). Any other value is treated as custom and implies nothing. Explicit variables below always override whatever the preset implied.

apps.detail.config.value
sonos-fast
AIRCONNECT_WEB_PORTVariable

Port for the web UI, JSON API and /healthz. Under host networking this port is taken directly on the host, so change it here if 8282 is already in use. The image default is 8080. If you change it, update the WebUI link on this template to match.

apps.detail.config.value
8282
AIRCONNECT_CODECVariable

Stream codec (-c). Options: flac[:0-9][/1152-16384] | wav | pcm | mp3[:rate] | aac[:rate]. Must be lossy (mp3 or aac) when AIRCONNECT_STREAM_TYPE is radio — a FLAC payload over x-rincon-mp3radio:// is fetched continuously and never played. FLAC also runs about 900 kbit/s, which a Sonos on a marginal wireless link will not sustain. Leave blank to use the preset.

apps.detail.config.value
mp3:320
AIRCONNECT_HTTP_LENGTHVariable

Content length advertised to the renderer (-g), airupnp only. Options: -3 (chunked) | -1 (advertise no length) | 0 (fixed — this means INT_MAX, NOT 'no length', so the renderer paces its reads as if fetching a 2 GiB track and stalls Sonos S2 30-50s in) | a byte count. Leave blank to use the preset.

apps.detail.config.value
-1
AIRCONNECT_STREAM_TYPEVariable

How a Sonos is told to present the stream (-S), airupnp only; every other renderer always gets audioBroadcast. Options: broadcast (audioBroadcast, no res@duration — the binary default) | track (upstream's musicTrack with a 1ms res@duration; combined with -g 0 this makes S2 firmware pace the stream like a file and drop it) | radio (audioBroadcast plus Sonos' x-rincon-mp3radio:// scheme, which removes the 10-15s startup wait; requires a lossy codec). Leave blank to pass no -S at all.

apps.detail.config.value
radio
AIRCONNECT_UPDATE_CHECKVariable

Check GitHub for a newer upstream release tag. Notify only — nothing ever self-updates. Options: true (default) | false. Also accepts 0, no, off and disabled as false; any other value is true.

apps.detail.config.value
true
TZVariable

Not read by AirConnect itself — glibc uses it to render local timestamps in logs and the web UI. Any tz database name, e.g. America/New_York, Europe/London, Etc/UTC.

apps.detail.config.value
America/New_York
AIRCONNECT_WEB_BINDVariable

Bind address for the web UI and API. Options: 0.0.0.0 (all interfaces, default) | 127.0.0.1 (localhost only) | a specific host IP.

apps.detail.config.value
0.0.0.0
AIRCONNECT_LATENCYVariable

Latency (-l), given as rtp:http[:f] in milliseconds, e.g. 50:100. Leave blank to use the preset.

AIRCONNECT_FLUSHVariable

Options: true (default) | false. Setting false passes --noflush to the bridges. Also accepts 0, no, off and disabled as false.

apps.detail.config.value
true
AIRCONNECT_DRIFTVariable

Clock drift correction. Options: false (default) | true. Setting true passes -r to the bridges.

apps.detail.config.value
false
AIRCONNECT_BIND_IPVariable

Interface used for discovery (-b). Accepts an IP address or an interface name. Set this on a multi-homed host, where discovery would otherwise bind the wrong interface. Leave blank to auto-detect.

AIRCONNECT_BIND_INTERFACEVariable

Alias for AIRCONNECT_BIND_IP. Only consulted when AIRCONNECT_BIND_IP is unset.

AIRCONNECT_UPNP_PORTVariable

UPnP control port, appended to the airupnp binding as -b ip:port. airupnp only. Must be 49152 or above. Leave blank to auto-assign.

AIRCONNECT_PORT_BASEVariable

Pins the per-device RTSP/RTP/HTTP port pool (-a) instead of using ephemeral ports, e.g. 49200. Leave blank for ephemeral ports.

AIRCONNECT_PORT_RANGEVariable

Size of the pinned port pool; only used when AIRCONNECT_PORT_BASE is set. Size it for at least (devices x 4) + 1, e.g. 128.

AIRCONNECT_NAME_FORMATVariable

Device name format (-N), applied to both bridges. %s is the discovered device name, so &quot;%s+&quot; appends a plus to each one.

AIRCONNECT_EXCLUDE_MODELSVariable

Comma-separated list of model names to skip (-m). airupnp only.

AIRCONNECT_EXCLUDE_NAMESVariable

Comma-separated list of device names to skip (-n). airupnp only.

AIRCONNECT_INCLUDE_MODELSVariable

Comma-separated allow-list of model names (-o). airupnp only.

AIRCONNECT_MEDIA_VOLUMEVariable

Media volume (-v). aircast only.

AIRCONNECT_LOG_MAINVariable

Starting log level for the bridges' main component (-d main=level). Options: error | warn | info | debug | sdebug. Starting value only — the web UI changes levels on a running bridge without a restart, which is how you capture a session without dropping it.

AIRCONNECT_LOG_UTILVariable

Starting log level for the util component (-d util=level). Options: error | warn | info | debug | sdebug. Changeable live from the web UI.

AIRCONNECT_LOG_RAOPVariable

Starting log level for the raop (AirPlay) component (-d raop=level). Options: error | warn | info | debug | sdebug. Changeable live from the web UI.

AIRCONNECT_LOG_UPNPVariable

Starting log level for the upnp component (-d upnp=level). Options: error | warn | info | debug | sdebug. Never passed to aircast. Changeable live from the web UI.

AIRCONNECT_LOG_CASTVariable

Starting log level for the cast component (-d cast=level). Options: error | warn | info | debug | sdebug. Never passed to airupnp. Changeable live from the web UI.

AIRCONNECT_LOGVariable

The manager's own tracing filter, unrelated to the bridge log levels above. Options: error | warn | info (default) | debug | trace, or any tracing EnvFilter directive such as airconnect_manager=debug.

apps.detail.config.value
info
AIRCONNECT_UPNP_EXTRA_ARGSVariable

Raw flags appended verbatim to airupnp. Simple double quoting is honoured, so -N &quot;Living Room %s&quot; survives. Do not pass -Z: it disables the bridge console and with it every live web UI feature (session detail, log streaming, runtime log levels).

AIRCONNECT_CAST_EXTRA_ARGSVariable

Raw flags appended verbatim to aircast. Simple double quoting is honoured, so -N &quot;Living Room %s&quot; survives. Do not pass -Z: it disables the bridge console and with it every live web UI feature (session detail, log streaming, runtime log levels).