Network traffic using Spotify Connect Client on WiiM Mini

twm

Member
Joined
Dec 16, 2022
Messages
15
I have my WiiM mini connected to an amplifier and I would like to turn on the amplifier automatically once music starts playing. This idea is to detect network traffic to the Wiim mini and if traffic is detected use an IR remote that is controlled via homebridge to turn on the amplifier. If I use

tcpdump dst IP-of-WiiM-Mini

on some linux server, I can reliably detect network traffic for music streamed from my UPNP server to the WiiM mini. However, tcpdump does not seem to detect network traffice for Spotify Connect. Is there a linux command line tool that I can use to detect network traffic to the WiiM mini when using spotify connect?
 
Thank you, that's very helpful! Unfortunately there is only a self-signed certificate in use for the https request. This results in trouble when trying to expose the WiiM status in Apple Homekit via homebridge (more specifically the plugin homebridge-http-advanced-accessory). Are there plans to expose the WiiM status over insecure http as well?
 
I don't think so, as it was available over insecure http before for other Linkplay modules and they switched to https after that. I'm afraid you have to handle self-signed cert yourself.
 
Another possibility is using UPnP interface as it is on http protocol. Linkplay provides GetInfoEx action over AVTransport.
 
Another possibility is using UPnP interface as it is on http protocol. Linkplay provides GetInfoEx action over AVTransport.
Yes, and you can subscribe to the service, so your app doesn't need to poll continuously. Plenty of UPnP libs out there for different languages.
 
So I used curl and and jq to parse the resulting JSON output, but this has the disadvantage of the need for continuous polling, as mentioned above. So the possibility to let the WiiM mini notify my linux server of a status change sounds intriguing, but I would appreciate some additional infos on how to go about this.
 
So I used curl and and jq to parse the resulting JSON output, but this has the disadvantage of the need for continuous polling, as mentioned above. So the possibility to let the WiiM mini notify my linux server of a status change sounds intriguing, but I would appreciate some additional infos on how to go about this.
Details would depend on technology you're going to use. For python you can find some info here:

 
I'm not a software engineer, but feel reasonably comfortable with python. Many thanks for pointing out this library!
 
Last edited:
I'm struggling to parse the output of async-upnp-client. For reasons I don't understand

upnp-client subscribe http://LAN_IP:49152/description.xml RC AVTransport | jq '.state_variables.TransportState'

shows me "PLAYING" if a new track starts, but nothing happens when I hit play/pause.
 
curl -s --insecure https://LAN_IP/httpapi.asp\?command=getPlayerStatus | jq -r '.status'

returns play or stop, depending of the status of the WiiM streamer. I'd like to have a similar functionality using upnp-client (so something like TRUE for playback and FALSE for idle/standby), but can't correctly parse the output of upnp-client.
 
Maybe it doesn't work because "upnp-client subscribe" doesn't return one time output and jq cannot handle results from the program which stays running?
 
Try this quick 'n dirty Python example. Change the ip address.

I originally tried subscribing with my display apps, but it was unreliable back then, so used polling instead. May be more reliable now after a dozen or more WiiM firmware updates, dunno.

Python:
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# pylint: disable=invalid-name

from time import sleep
import requests

import asyncio
import json
import sys
import time
import xmltodict
from datetime import datetime
from typing import Any, Optional, Sequence, Tuple, Union, cast
from collections import OrderedDict

from async_upnp_client.advertisement import SsdpAdvertisementListener
from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpRequester
from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable
from async_upnp_client.client_factory import UpnpFactory
from async_upnp_client.const import NS, AddressTupleVXType, SsdpHeaders
from async_upnp_client.exceptions import UpnpResponseError
from async_upnp_client.profiles.dlna import dlna_handle_notify_last_change
from async_upnp_client.search import async_search as async_ssdp_search
from async_upnp_client.ssdp import SSDP_IP_V4, SSDP_IP_V6, SSDP_PORT, SSDP_ST_ALL
from async_upnp_client.utils import get_local_ip

items = {}
art = ""

pprint_indent = 4

event_handler = None
playing = False

async def create_device(description_url: str) -> UpnpDevice:
    """Create UpnpDevice."""
    timeout = 60
    non_strict = True
    requester = AiohttpRequester(timeout)
    factory = UpnpFactory(requester, non_strict=non_strict)
    return await factory.async_create_device(description_url)


def get_timestamp() -> Union[str, float]:
    """Timestamp depending on configuration."""
    return time.time()


def service_from_device(device: UpnpDevice, service_name: str) -> Optional[UpnpService]:
    """Get UpnpService from UpnpDevice by name or part or abbreviation."""
    for service in device.all_services:
        part = service.service_id.split(":")[-1]
        abbr = "".join([c for c in part if c.isupper()])
        if service_name in (service.service_type, part, abbr):
            return service

    return None

def on_event(
    service: UpnpService, service_variables: Sequence[UpnpStateVariable]
) -> None:
    """Handle a UPnP event."""
    obj = {
        "timestamp": get_timestamp(),
        "service_id": service.service_id,
        "service_type": service.service_type,
        "state_variables": {sv.name: sv.value for sv in service_variables},
    }
    global playing
   
    # special handling for DLNA LastChange state variable
    if len(service_variables) == 1 and service_variables[0].name == "LastChange":
        last_change = service_variables[0]
        dlna_handle_notify_last_change(last_change)
    else:
        for sv in service_variables:
            ### PAUSED, PLAYING, STOPPED, etc
            #print(sv.name,sv.value)
            if sv.name == "TransportState":
                print(sv.value)

            ### Grab and print the metadata
            if sv.name == "CurrentTrackMetaData" or sv.name == "AVTransportURIMetaData":
                ### Convert the grubby XML to beautiful JSON, because we HATE XML!
                items = xmltodict.parse(sv.value)["DIDL-Lite"]["item"]
                ### Print the entire mess
                print(json.dumps(items,indent=4))


async def subscribe(description_url: str, service_names: Any) -> None:
    """Subscribe to service(s) and output updates."""
    global event_handler  # pylint: disable=global-statement

    device = await create_device(description_url)

    # start notify server/event handler
    source = (get_local_ip(device.device_url), 0)
    server = AiohttpNotifyServer(device.requester, source=source)
    await server.async_start_server()

    # gather all wanted services
    if "*" in service_names:
        service_names = device.services.keys()

    services = []

    for service_name in service_names:
        service = service_from_device(device, service_name)
        if not service:
            print(f"Unknown service: {service_name}")
            sys.exit(1)
        service.on_event = on_event
        services.append(service)

    # subscribe to services
    event_handler = server.event_handler
    for service in services:
       try:
            await event_handler.async_subscribe(service)
       except UpnpResponseError as ex:
            print("Unable to subscribe to %s: %s", service, ex)

    s = 0
    while True:
        await asyncio.sleep(10)
        s = s + 1
        if s >= 12:
          await event_handler.async_resubscribe_all()
          s = 0

async def async_main() -> None:
    """Async main."""

    ####  NOTICE!!!! #####################################
    ####  Your WiiM Mini's IP and port go here
    device = "http://192.168.68.124:49152/description.xml"
    ####             #####################################
    service = ["AVTransport"]

    await subscribe(device, service)


def main() -> None:
    """Set up async loop and run the main program."""
    loop = asyncio.get_event_loop()

    try:
        loop.run_until_complete(async_main())
    except KeyboardInterrupt:
        if event_handler:
            loop.run_until_complete(event_handler.async_unsubscribe_all())
    finally:
        loop.close()


if __name__ == "__main__":
    main()

Code:
python3 wiim.py
PLAYING
{
    "@id": "",
    "song:subid": null,
    "song:description": null,
    "song:skiplimit": "0",
    "song:id": "256001760",
    "song:like": "0",
    "song:singerid": "35160768",
    "song:albumid": "256001750",
    "song:quality": "2",
    "song:actualQuality": "LOSSLESS",
    "song:atmos": "0",
    "song:guibehavior": null,
    "res": {
        "@protocolInfo": "http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;",
        "@duration": "00:12:42.000"
    },
    "dc:title": "It\u2019s Time",
    "upnp:artist": "Junyan Chen",
    "upnp:album": "It\u2019s Time: Royal Academy of Music Bicentenary Series",
    "upnp:albumArtURI": "https://resources.tidal.com/images/ca6f4676/5224/46c6/b587/59bc6bebf70e/640x640.jpg",
    "song:rate_hz": "44100",
    "song:format_s": "16",
    "song:bitrate": "334"
}
PAUSED_PLAYBACK
{
    "@id": "",
    "song:subid": null,
    "song:description": null,
    "song:skiplimit": "0",
    "song:id": "256001756",
    "song:like": "0",
    "song:singerid": "35160768",
    "song:albumid": "256001750",
    "song:quality": "2",
    "song:actualQuality": "LOSSLESS",
    "song:atmos": "0",
    "song:guibehavior": null,
    "res": {
        "@protocolInfo": "http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;",
        "@duration": "00:08:32.000"
    },
    "dc:title": "Black Earth, Op. 8",
    "upnp:artist": "Junyan Chen",
    "upnp:album": "It\u2019s Time: Royal Academy of Music Bicentenary Series",
    "upnp:albumArtURI": "https://resources.tidal.com/images/ca6f4676/5224/46c6/b587/59bc6bebf70e/640x640.jpg",
    "song:rate_hz": "44100",
    "song:format_s": "16",
    "song:bitrate": "329"
}
{
    "@id": "",
    "song:subid": null,
    "song:description": null,
    "song:skiplimit": "0",
    "song:id": "256001756",
    "song:like": "0",
    "song:singerid": "35160768",
    "song:albumid": "256001750",
    "song:quality": "2",
    "song:actualQuality": "LOSSLESS",
    "song:atmos": "0",
    "song:guibehavior": null,
    "res": {
        "@protocolInfo": "http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;",
        "@duration": "00:08:32.000"
    },
    "dc:title": "Black Earth, Op. 8",
    "upnp:artist": "Junyan Chen",
    "upnp:album": "It\u2019s Time: Royal Academy of Music Bicentenary Series",
    "upnp:albumArtURI": "https://resources.tidal.com/images/ca6f4676/5224/46c6/b587/59bc6bebf70e/640x640.jpg",
    "song:rate_hz": "44100",
    "song:format_s": "16",
    "song:bitrate": "329"
}
TRANSITIONING
{
    "@id": "",
    "song:subid": null,
    "song:description": null,
    "song:skiplimit": "0",
    "song:id": "256001756",
    "song:like": "0",
    "song:singerid": "35160768",
    "song:albumid": "256001750",
    "song:quality": "2",
    "song:actualQuality": "LOSSLESS",
    "song:atmos": "0",
    "song:guibehavior": null,
    "res": {
        "@protocolInfo": "http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;",
        "@duration": "00:08:32.000"
    },
    "dc:title": "Black Earth, Op. 8",
    "upnp:artist": "Junyan Chen",
    "upnp:album": "It\u2019s Time: Royal Academy of Music Bicentenary Series",
    "upnp:albumArtURI": "https://resources.tidal.com/images/ca6f4676/5224/46c6/b587/59bc6bebf70e/640x640.jpg",
    "song:rate_hz": "44100",
    "song:format_s": "16",
    "song:bitrate": "329"
}
PLAYING
 
After some initial testing it looks like it may still not be entirely realiable (in the sense that the service stops working after a while). Many thanks nonetheless!
 
Back
Top