BUSY.Tedt.org BUSY Bar Apps
← All Apps

Sonos Now Playing

Shows the currently playing Sonos track and artist on the BUSY Bar. Auto-discovers speakers; clears the display when playback stops.

Updated Oct 15, 2026 · Added Oct 15, 2026 · ↓ 0 package download starts

By bret@thewortmans.org · ▲ 0 · Rate and Discuss

How It Works

Shows the currently playing Sonos track and artist on the BUSY Bar. Auto-discovers speakers; clears the display when playback stops.

Download and extract the complete app folder. Keep its files together; some apps include additional modules, assets, or configuration examples.

Install the app’s declared Python dependencies in a virtual environment:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt

Review app.py and any configuration examples for this app’s options, then run it from the extracted folder:

python3 app.py

Connect BUSY Bar over USB, or use the connection options supported by this app. See the official HTTP API documentation for device setup.

Declared Dependencies

busylib>=1.6.0
soco>=0.30.0

Source Files

These are the mirrored source bytes. The complete package also includes every asset and supporting file.

app.py
#!/usr/bin/env python3
"""Display the currently playing Sonos track on a BUSY Bar.

    +--------------------+---------------------------+
    |  SONG TITLE HERE                                |   scrolling top line
    |  Artist Name                                    |   smaller bottom line
    +--------------------+---------------------------+

Polls the selected Sonos speaker every few seconds. Shows nothing when
paused/stopped. Auto-discovers speakers on the local network if no
--speaker-ip is given; use --speaker-name to select by room name.
If no speaker is specified, the first speaker alphabetically is used.

Requires busylib and soco:
    pip install busylib soco
"""

from __future__ import annotations

import argparse
import asyncio
import contextlib
import logging
import signal
import sys
from dataclasses import dataclass

import soco
import soco.exceptions

from busylib import AsyncBusyBar
from busylib.exceptions import BusyBarError
from busylib.features import notify

LOG = logging.getLogger("sonos-now-playing")

APP_NAME = "sonos-now-playing"
PRIORITY = 50  # PRIORITY_DEFAULT


@dataclass
class TrackInfo:
    title: str
    artist: str
    is_playing: bool

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, TrackInfo):
            return False
        return self.title == other.title and self.artist == other.artist and self.is_playing == other.is_playing


def find_speaker(ip: str | None, name: str | None) -> soco.SoCo | None:
    if ip:
        return soco.SoCo(ip)
    speakers = soco.discover(timeout=5)
    if not speakers:
        LOG.warning("no Sonos speakers found on the network")
        return None
    # Sort for stable selection across restarts
    sorted_speakers = sorted(speakers, key=lambda s: s.player_name.lower())
    LOG.info("available speakers: %s", ", ".join(
        f"{s.player_name} ({s.ip_address})" for s in sorted_speakers))
    if name:
        name_lower = name.lower()
        for s in sorted_speakers:
            if s.player_name.lower() == name_lower:
                return s
        LOG.warning("no speaker named %r; use one of the names above", name)
        return None
    # Pick the first coordinator alphabetically (avoids picking a grouped follower)
    coordinators = [s for s in sorted_speakers if s.is_coordinator]
    chosen = coordinators[0] if coordinators else sorted_speakers[0]
    LOG.info("auto-selected %r — use --speaker-name to pick a different one", chosen.player_name)
    return chosen


def get_track(speaker: soco.SoCo) -> TrackInfo | None:
    try:
        info = speaker.get_current_transport_info()
        state = info.get("current_transport_state", "STOPPED")
        is_playing = state == "PLAYING"

        track = speaker.get_current_track_info()
        title = (track.get("title") or "").strip()
        artist = (track.get("artist") or "").strip()

        if not title and not artist:
            return None
        return TrackInfo(title=title or "Unknown", artist=artist, is_playing=is_playing)
    except soco.exceptions.SoCoException as exc:
        LOG.warning("Sonos error: %s", exc)
        return None
    except Exception as exc:  # noqa: BLE001
        LOG.warning("unexpected error polling speaker: %s", exc)
        return None


async def draw_track(bar: AsyncBusyBar, track: TrackInfo) -> None:
    if not track.is_playing:
        with contextlib.suppress(BusyBarError):
            await bar.display_clear(application_name=APP_NAME)
        return

    line1 = track.title[:40]
    line2 = track.artist[:40] if track.artist else None

    try:
        if line2:
            await notify(
                bar,
                line1,
                line_2=line2,
                line_1_font="normal",
                line_2_font="tiny",
                line_1_color=[255, 255, 255],
                line_2_color=[0, 200, 80],
                priority=PRIORITY,
                application_name=APP_NAME,
                duration=0,
            )
        else:
            await notify(
                bar,
                line1,
                line_1_font="normal",
                line_1_color=[255, 255, 255],
                priority=PRIORITY,
                application_name=APP_NAME,
                duration=0,
            )
    except BusyBarError as exc:
        if "409" not in str(exc):
            LOG.warning("draw failed: %s", exc)


async def run(args: argparse.Namespace) -> None:
    speaker = find_speaker(args.speaker_ip, args.speaker_name)
    if speaker is None:
        LOG.error("no speaker available; exiting")
        sys.exit(1)

    LOG.info("monitoring %r (%s)", speaker.player_name, speaker.ip_address)

    async with AsyncBusyBar(args.host) as bar:
        stop = asyncio.Event()
        loop = asyncio.get_running_loop()
        for sig in (signal.SIGINT, signal.SIGTERM):
            with contextlib.suppress(NotImplementedError):
                loop.add_signal_handler(sig, stop.set)

        last: TrackInfo | None = None

        while not stop.is_set():
            track = await loop.run_in_executor(None, get_track, speaker)

            if track != last:
                if track is not None:
                    await draw_track(bar, track)
                elif last is not None:
                    with contextlib.suppress(BusyBarError):
                        await bar.display_clear(application_name=APP_NAME)
                last = track

            with contextlib.suppress(asyncio.TimeoutError):
                await asyncio.wait_for(stop.wait(), timeout=args.interval)

        LOG.info("stopping")
        with contextlib.suppress(BusyBarError):
            await bar.display_clear(application_name=APP_NAME)


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--host", default="10.0.4.20",
                   help="busybar-manager proxy or BUSY Bar address (injected by the manager)")
    p.add_argument("--speaker-ip", metavar="IP",
                   help="Sonos speaker IP address (auto-discovers if omitted)")
    p.add_argument("--speaker-name", metavar="NAME",
                   help="Sonos room/speaker name to select (e.g. 'Living Room'); "
                        "defaults to first speaker alphabetically")
    p.add_argument("--interval", type=float, default=5.0,
                   help="seconds between polls (default 5)")
    p.add_argument("--once", action="store_true",
                   help="draw one frame and exit (useful for tests)")
    p.add_argument("-v", "--verbose", action="store_true")
    args = p.parse_args(argv)

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(asctime)s %(levelname)-7s %(message)s",
        datefmt="%H:%M:%S",
    )

    if args.once:
        speaker = find_speaker(args.speaker_ip, args.speaker_name)
        if speaker is None:
            return 1
        track = get_track(speaker)
        if track:
            print(f"{track.title} — {track.artist} ({'playing' if track.is_playing else 'paused'})")
        else:
            print("nothing playing")
        return 0

    try:
        asyncio.run(run(args))
    except KeyboardInterrupt:
        pass
    return 0


if __name__ == "__main__":
    sys.exit(main())
manifest.yaml
name: Sonos Now Playing
author: bret@thewortmans.org
description: Shows the currently playing Sonos track and artist on the BUSY Bar. Auto-discovers speakers; clears the display when playback stops.
tags:
  - music
  - audio
  - monitor
preview: ./preview.gif
requirements.txt
busylib>=1.6.0
soco>=0.30.0

Source and Provenance

View This Version on GitHub →

Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: da7998e8474d171bb63d30d6b1c253f119ee07470ebdec6e1cf740b912a0854b

The Community Workbench

Rate and Discuss

Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.