For my personal convenience, I am in process of writing the One App to Rule them All: a single macOS menu bar / iOS app that carries all primary control functions of my most used speakers in one place: KEF LS60, Meridian Ellipse, Naim Mu-so 2. So far, I have finished KEF and Meridian. The usual stuff is present, with a few surprises along the way (EQ profile selection and radio on the LS60, a hidden equalizer on Meridian). As all devices are on StreamUnlimited’s platform (with a few local dialects just for fun), wrapping them into one is actually the easy part. ![]()
Small glimpse at the KEF app to see what I am yapping about:
FLTR: main screen (source & EQ selection), volume presets, shortcut/webhook presets, radio presets (read from the spreaker).
This weekend, I am eyeing the Mu-so – the party starts with a scan of the device. As threads covering the API have come up every now and then, I’ve asked my little brother Claude to summarize the findings as to benefit anyone who likes to tinker with their NAIM.
Anyway – enjoy.
I’ll update how the NAIM app turned out, if there’s any interest…
The Naim Mu-so 2 HTTP Bible
For the hobbyist who wants to drive their speaker from curl, Apple
Shortcuts, Home Assistant, a Stream Deck, or anything else that speaks
HTTP. Everything runs on the local network — no authentication, no
cloud.
Probed and verified on a Mu-so 2, firmware 4.5.0.6644, 2026-08-08 —
including a live session against internet radio and a Spotify
Connect stream.
= verified live on the device,
The device
| IP | 192.168.1.154 (example — see discovery) |
| Hostname | muso-1503031748.local |
| Discovery | Bonjour _sues800device._tcp (instance naims810x-…; TXT record carries ip=, name=, serial=) |
| API 1 | Naim REST on port 15081 — power, inputs, favourites, now-playing, volume, all transport |
| API 2 | StreamUnlimited (SUES) on port 80 — volume, mute, rich metadata, tone |
The one rule that keeps you out of trouble 
Use port 15081 for all transport. The SUES transport verbs are
booby-trapped on this device: against a live Spotify Connect stream,
SUES pause and seekTime work — but SUES play and next_ KILL
the Spotify session outright (state drops to stopped, music gone).
The antidote, also verified: GET /inputs/spotify?cmd=select revives
the dropped session without touching the phone. The 15081 verbs
(playpause, next, prev, stop) all behave impeccably.
Two more behavioural facts
:
- Radio is invisible to SUES. While internet radio plays via
15081, SUESplayer:player/datareportsstopped. Spotify Connect
is mirrored by SUES (full metadata). Read radio state only from
15081. - Standby: in network standby (“lona”) port 80 keeps answering;
on 15081/poweralways answers, but/nowplayingand/levels
only respond while awake.
API 1 — Naim REST (port 15081)
Plain GETs and PUTs; responses are JSON. Objects carry a ussi
(path), a class, and sometimes children.
Power 
# Status: state is "on" (awake) or "lona" (network standby)
curl "http://192.168.1.154:15081/power"
# Wake
curl -X PUT "http://192.168.1.154:15081/power?system=on"
# Back to standby
curl -X PUT "http://192.168.1.154:15081/power?system=lona"
Volume & mute 
# Read: {"volume": "28", "mute": "0", ...}
curl "http://192.168.1.154:15081/levels/room"
# Set (absolute, 0-100)
curl -X PUT "http://192.168.1.154:15081/levels/room?volume=25"
# Mute on / off
curl -X PUT "http://192.168.1.154:15081/levels/room?mute=1"
curl -X PUT "http://192.168.1.154:15081/levels/room?mute=0"
Volume up/down: there is no native step verb
— the obvious
candidates (?cmd=volup etc.) are accepted but do nothing, and a
relative value is dangerously misparsed: volume=+2 sets the
volume to literal 2
(imagine the reverse). Step by reading and
writing back:
# Volume up by 2
M=192.168.1.154
V=$(curl -s "http://$M:15081/levels/room" | python3 -c "import json,sys;print(min(100,int(json.load(sys.stdin)['volume'])+2))")
curl -s -X PUT "http://$M:15081/levels/room?volume=$V"
(Shortcuts version: Get contents of URL → Get dictionary value
volume → Calculate +2 → Get contents of …?volume=[result].)
Transport
— the safe and only full set
# Toggle pause/resume (verified on Spotify: transportState 2⇄3)
curl "http://192.168.1.154:15081/nowplaying?cmd=playpause"
# Next / previous track (verified on Spotify)
curl "http://192.168.1.154:15081/nowplaying?cmd=next"
curl "http://192.168.1.154:15081/nowplaying?cmd=prev"
# Stop (verified on radio)
curl "http://192.168.1.154:15081/nowplaying?cmd=stop"
Which verbs the current source honours is advertised in the
restrict* flags of /nowplaying (0 = allowed, 128 =
restricted): Spotify allows everything; live radio allows only
stop/resume.
Now playing 
curl "http://192.168.1.154:15081/nowplaying"
Awake only. Key fields, seen live:
| Field | Meaning |
|---|---|
source |
e.g. inputs/spotify, inputs/radio |
transportState |
2 = playing, 3 = paused |
transportPosition |
live play position in ms, ticking |
artistName, albumName |
Spotify metadata |
station, genre, country, bitRate |
radio metadata |
artwork |
real URL (Spotify: i.scdn.co; radio: station logo) |
restrict* |
transport permissions per source |
Inputs
# All 16 inputs with their status
curl "http://192.168.1.154:15081/inputs"
# Select a source ✅
curl "http://192.168.1.154:15081/inputs/dig?cmd=select"
# Revive a dropped Spotify Connect session ✅
curl "http://192.168.1.154:15081/inputs/spotify?cmd=select"
Selectable (selectable: 1): inputs/ana (3.5 mm), inputs/dig
(optical), inputs/hdmi (ARC), inputs/radio, inputs/spotify,
inputs/playqueue. Passive (activate on arrival): airplay, gcast,
tidal, qobuz, bluetooth, upnp, usb, multiroom, files,
qq.
Internet radio & favourites 
# Your favourites list (British spelling!)
curl "http://192.168.1.154:15081/favourites"
# Play one — starts the station immediately ✅
curl "http://192.168.1.154:15081/favourites/<32-char-id>?cmd=play"
# Or activate the radio input (resumes the last station)
curl "http://192.168.1.154:15081/inputs/radio?cmd=select"
/favourites returns children, each with a name and a ussi of
the form favourites/<32-char-id>. These double as the device’s real
preset system — the ten SUES presets: slots are all “Empty”
.
Read-only info (always available)
| Endpoint | What |
|---|---|
/system |
Model, firmware (build), serial, Chromecast details |
/network |
Interface, SSID, IP configuration |
/update |
Firmware update status |
/alarms |
Alarm clocks (empty here) |
API 2 — StreamUnlimited (port 80)
The KEF/Ellipse dialect: three verbs under /api, with typed JSON
values.
| Verb | Shape |
|---|---|
| Read | GET /api/getData?path=<path>&roles=value |
| List | GET /api/getRows?path=<path>&roles=@all&from=0&to=99 |
| Write | GET /api/setData?path=<path>&roles=value&value=<json> |
The value is always a typed object, URL-encoded:
{"type":"i32_","i32_":28} (integer), {"type":"bool_","bool_":true},
{"type":"double_","double_":-2.5}, {"type":"string_","string_":"…"}.
Volume
and mute 
curl "http://192.168.1.154/api/getData?path=player:volume&roles=value"
curl "http://192.168.1.154/api/setData?path=player:volume&roles=value&value=%7B%22type%22%3A%22i32_%22%2C%22i32_%22%3A25%7D"
curl "http://192.168.1.154/api/getData?path=settings:/mediaPlayer/mute&roles=value"
curl "http://192.168.1.154/api/setData?path=settings:/mediaPlayer/mute&roles=value&value=%7B%22type%22%3A%22bool_%22%2C%22bool_%22%3Atrue%7D"
Player data
(the rich read — Spotify/streams only)
curl "http://192.168.1.154/api/getData?path=player:player/data&roles=value"
Verified against a live Spotify Connect stream: state: playing,
trackRoles.title, mediaData.metaData (artist/album),
trackRoles.icon (artwork URL), status.duration, and — the gem —
resources[0].codec: "Free Lossless Audio Codec (FLAC 24bit)" with
bitRate ≈ 1.2 Mbps: your Spotify Lossless badge, for free
.
The controls object advertises pause/previous/next_/seekTime — but
see the booby-trap warning: only use it for reading. Radio does not
appear here at all.
Transport on SUES — handle with gloves
| Verb | Effect on a Spotify stream |
|---|---|
pause |
pauses playpause) |
seekTime (ms) |
seeks, keeps playing |
play |
kills the session |
next_ / previous |
kills the session |
# The one useful write: seek to 1:00 (ms)
curl "http://192.168.1.154/api/setData?path=player:player/control&roles=activate&value=%7B%22control%22%3A%22seekTime%22%2C%22time%22%3A60000%7D"
Recovery from a killed session: GET /inputs/spotify?cmd=select on
port 15081
.
Tone 
Under settings:/mediaPlayer (double_, family range ±10 dB in 0.5
steps
for the exact bounds):
| Path | What | Status |
|---|---|---|
settings:/mediaPlayer/bass |
Bass | read+write |
settings:/mediaPlayer/treble |
Treble | read+write |
settings:/mediaPlayer/balance |
Balance | write i32_, accepts double_ writes |
settings:/mediaPlayer/loudness |
Loudness | bool, read+write |
# Bass to +1.5 dB
curl "http://192.168.1.154/api/setData?path=settings:/mediaPlayer/bass&roles=value&value=%7B%22type%22%3A%22double_%22%2C%22double_%22%3A1.5%7D"
Beware: the tree also holds variants like balance_Diva and
balance_NSS333 — other Naim models sharing the firmware; the bare
node is yours.
Other confirmed paths (reads)
| Path | What |
|---|---|
settings:/deviceName |
“Naim Mu-so 2” — writable in the family |
settings:/system/modelName |
“Mu-so” |
presets: (getRows) |
10 slots, all “Empty” /favourites instead |
grouping: (getRows) |
SUES multiroom (4 nodes) |
spotify: / airplay: / localfile: (getRows) |
Service trees |
settings:/ (getRows) |
The full settings tree, 66 branches |
Family quirk
: getRows on a leaf errors — use getData for
leaves. Bare schemes like airable: and ui: do not exist on the
Naim (the Ellipse’s ui: duties are all on 15081 here).
Recipes
M=192.168.1.154
# Morning: wake + Radio Paradise
curl -s -X PUT "http://$M:15081/power?system=on" && sleep 2 && \
curl -s "http://$M:15081/favourites/972c3eaa8312470bb2a21e43dbbbd447?cmd=play"
# Evening: everything off
curl -s -X PUT "http://$M:15081/power?system=lona"
# Volume up a notch (read, +2, write)
V=$(curl -s "http://$M:15081/levels/room" | python3 -c "import json,sys;print(min(100,int(json.load(sys.stdin)['volume'])+2))") && \
curl -s -X PUT "http://$M:15081/levels/room?volume=$V"
# Pause/resume whatever plays
curl -s "http://$M:15081/nowplaying?cmd=playpause"
# One-line status (station, or artist — track, or idle)
curl -s "http://$M:15081/nowplaying" | python3 -c "
import json,sys
d = json.load(sys.stdin)
print(d.get('station') or ' — '.join(x for x in (d.get('artistName'), d.get('name')) if x) or 'idle')"
Apple Shortcuts: every line above is one “Get contents of URL”
action (set the method to GET or PUT). No headers or body needed —
even the SUES writes carry their value in the URL.
Home Assistant: rest_command: entries for the actions, plus a
rest sensor on /power (state) for on/off and one on
/nowplaying (transportState, transportPosition) while awake.

