Skip to content

feat: SSDP device discovery (wiim discover) - #12

Merged
zzwong merged 3 commits into
mainfrom
discover/ssdp-command
Jul 5, 2026
Merged

feat: SSDP device discovery (wiim discover)#12
zzwong merged 3 commits into
mainfrom
discover/ssdp-command

Conversation

@zzwong

@zzwong zzwong commented Jul 5, 2026

Copy link
Copy Markdown
Owner

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 discover multicasts an SSDP M-SEARCH (ST: upnp:rootdevice) to 239.255.255.250:1900 and collects the source IP of every UDP reply — stdlib net only, 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 — it ties into the existing docs/api.md#compatibility story.

wiim discover
wiim --json discover
wiim --timeout 5 discover   # override the default 3s SSDP wait window

Scope note per the issue: this deliberately doesn't go as deep as pywiim's discovery (no --no-validate network-scan fallback, no UPnP description.xml capability 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 existing newDevice pattern used throughout cli_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 (ssdpSearch with 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: discover added 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 a discover row in the command table.
  • skills/wiim/SKILL.md: tells an agent to try wiim discover (no --host needed, 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 tests
  • gofmt -l . — clean, golangci-lint run ./... — 0 issues
  • Manual: built the binary and ran wiim discover, wiim --json discover, and wiim --timeout 1 discover on 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)
  • Manual verification against a real WiiM/Linkplay device on an actual LAN would be good to confirm end-to-end, since this sandbox has no such device to test against

zzwong added 2 commits July 5, 2026 15:25
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.
@zzwong

zzwong commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

Reviewed this PR and found two real gaps, plus hardened one edge case — pushed a follow-up commit (692dd19) addressing all of them:

Gap 1 — config-file timeout ignored: 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. Verified both directions manually (config-file timeout honored, --timeout 0 now a proper usage error) and with new regression tests.

Gap 2 — SSDP MX didn't track the listen window: MX (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. Added a test locking in that invariant.

Hardening: ssdpSearch's read loop 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 now surfaces as an error instead of silently looking like "found nothing."

Also documented the --timeout/MX interaction and a multi-homed-host interface nuance (multicast goes out whichever interface the OS default route picks) in docs/api.md's Discovery section.

All green: go build/vet/test -race/gofmt/golangci-lint clean.

@zzwong
zzwong merged commit a7ff72d into main Jul 5, 2026
4 checks passed
@zzwong
zzwong deleted the discover/ssdp-command branch July 5, 2026 19:55
@zzwong zzwong mentioned this pull request Jul 5, 2026
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Device discovery (SSDP/UPnP or network scan) for zero-config agent/first-run use

1 participant