feat: SSDP device discovery (wiim discover) - #12
Conversation
Closes #7. Previously there was no way to find a WiiM/Linkplay device without already knowing its host/IP — a hard blocker for first-run setup and for an agent asked to control "my WiiM speaker" with nothing configured yet. wiim discover multicasts an SSDP M-SEARCH (ST: upnp:rootdevice) and collects the source IP of every UDP reply, using only stdlib net (no new dependency). Since any UPnP device answers that search — smart TVs, printers, routers, not just Linkplay speakers — every responding IP is then validated with a direct getStatusEx call; only hosts that actually answer the WiiM HTTP API make it into the result. This also means discover works for any Linkplay device, not just WiiM, since validation doesn't check for anything WiiM-specific. wiim discover wiim --json discover wiim --timeout 5 discover # override the default 3s SSDP wait Network I/O (ssdpSearchFunc) and the validation HTTP call (newDiscoveryClient) are both swappable package vars, mirroring the existing newDevice pattern, so command/formatting logic is fully unit tested without real network access. One test does exercise the real UDP socket path (short timeout, asserts no error/no hang — verified this doesn't require multicast group membership, just an outbound send and a normal unicast-reply listen). Updated README, docs/api.md, and SKILL.md to describe it; SKILL.md tells an agent to try `wiim discover` (no --host required) before asking the user for a host.
Review turned up two real gaps: 1. runDiscover read a.opts.timeout directly and substituted 3.0 for anything <= 0, instead of going through the cliTimeout()/ ResolveTimeout() path every other command uses. A timeout set in ~/.config/wiim-cli/config.json was silently ignored for discover specifically, and an explicit --timeout 0 silently became 3.0 instead of the usage error ResolveTimeout raises everywhere else. Fixed by routing runDiscover through the same a.loadConfig() + ResolveTimeout(a.cliTimeout(), cfg) call every other command makes. 2. The SSDP MX value (how long we tell devices they may randomize their reply delay) was hardcoded to 2 regardless of the actual listen timeout. "wiim --timeout 1 discover" asked devices for a 2s reply window but only listened for 1s, quietly dropping any reply that arrived in the second half of the window it advertised. Extracted the clamping into ssdpMX(timeout) (clamped to [1, 5]) so the advertised MX never exceeds how long ssdpSearch actually waits. Also hardened ssdpSearch's read loop: it used to treat every ReadFromUDP error as "deadline hit, done collecting." It now checks Timeout() specifically, and only swallows a non-timeout error as "done" if something was already collected — an early, non-timeout failure (e.g. the socket breaking) now surfaces as an error instead of silently looking like "found nothing." Added regression tests for the config-file timeout, the --timeout 0 rejection, and the MX-vs-listen-window invariant. Documented the --timeout/MX interaction and a multi-homed-host interface nuance in docs/api.md's Discovery section.
|
Reviewed this PR and found two real gaps, plus hardened one edge case — pushed a follow-up commit ( Gap 1 — config-file timeout ignored: Gap 2 — SSDP Hardening: Also documented the All green: |
# Conflicts: # internal/wiim/cli_test.go
Summary
Closes #7. Previously there was no way to find a WiiM/Linkplay device without already knowing its host/IP — a hard blocker for first-run setup, and for an agent asked to control "my WiiM speaker" with nothing configured yet.
wiim discovermulticasts an SSDPM-SEARCH(ST: upnp:rootdevice) to239.255.255.250:1900and collects the source IP of every UDP reply — stdlibnetonly, no new dependency. Since any UPnP device answers that search (smart TVs, printers, routers — not just Linkplay speakers), every responding IP is then validated with a directgetStatusExcall; only hosts that actually answer the WiiM HTTP API make it into the result. This also meansdiscoverworks for any Linkplay device, not just WiiM, since validation doesn't check for anything WiiM-specific — it ties into the existingdocs/api.md#compatibilitystory.wiim discover wiim --json discover wiim --timeout 5 discover # override the default 3s SSDP wait windowScope note per the issue: this deliberately doesn't go as deep as
pywiim's discovery (no--no-validatenetwork-scan fallback, no UPnPdescription.xmlcapability enrichment) — just enough to remove the "how do I even get a host" blocker.Design for testability
Network I/O (
ssdpSearchFunc) and the validation HTTP call (newDiscoveryClient) are both swappable package vars, mirroring the existingnewDevicepattern used throughoutcli_test.go. Command wiring, filtering (non-WiiM responders dropped), sorting, and formatting are all unit tested against fakes — no real network needed. One test does exercise the real UDP socket path directly (ssdpSearchwith a 150ms timeout), asserting it completes without error/hanging when nothing responds; confirmed this doesn't require joining the multicast group, just an outbound send + a normal unicast-reply listen on the same socket.Docs
README.md:discoveradded to the command list, a "Configuration" callout for "don't know your host yet," and a usage note on what it filters and why.docs/api.md: new "Discovery" section explaining the SSDP mechanism and its limits (IPv4 only, no cross-subnet discovery), plus adiscoverrow in the command table.skills/wiim/SKILL.md: tells an agent to trywiim discover(no--hostneeded, read-only, safe to run unprompted) before asking the user for a host.Test plan
go build ./...,go vet ./...go test ./... -race— all passing, including new discovery/format/command testsgofmt -l .— clean,golangci-lint run ./...— 0 issueswiim discover,wiim --json discover, andwiim --timeout 1 discoveron this sandbox's network (no WiiM device present) — all completed cleanly in the expected time with "No devices found." /[], exit code 0 (not treated as a failure)