My App

Squeezeplay UDAP cross-check

How go-udap's UDAP implementation compares to ralph-irving/squeezeplay

Purpose

This note records a comparison of go-udap's UDAP implementation against the Lua reference implementation in ralph-irving/squeezeplay. Squeezeplay is a software music player for Lyrion Music Server; it both responds to UDAP requests (acting as a device) and issues UDAP requests (during its own setup wizard).

Authority hierarchy: Net::UDAP (Perl) remains the canonical protocol authority for go-udap. This comparison treats squeezeplay as a cross-check — divergences between squeezeplay and Net::UDAP are flagged below but Net::UDAP wins for canonical claims.

Two questions this note answers:

  1. How does squeezeplay's UDAP wire protocol compare to ours? Where do we diverge, intentionally or otherwise?
  2. Can devices other than the Squeezebox Receiver be configured via UDAP?

Methodology

Files read (captured from ralph-irving/squeezeplay@master, 2026-05-13):

  • src/squeezeplay/share/jive/net/Udap.lua — core protocol module (packet framing, send/recv).
  • src/squeezeplay/share/applets/UdapControl/UdapControlApplet.lua — the applet that responds to UDAP requests when squeezeplay is acting as a device.
  • src/squeezeplay/share/applets/SetupSqueezebox/SetupSqueezeboxApplet.lua — the setup wizard that issues UDAP requests to onboard hardware Squeezeboxen.
  • src/squeezeplay/share/applets/SlimDiscovery/SlimDiscoveryApplet.lua — periodic discovery driver.
  • src/squeezeplay/share/applets/SelectPlayer/SelectPlayerApplet.lua, src/squeezeplay/share/jive/slim/Player.lua, src/squeezeplay/share/jive/net/SlimProto.lua — adjacent files with small UDAP touch points.

Compared against go-udap at the merge of PR #43 (commit 3612854).

1. Packet framing and protocol primitives

Header layout — mostly aligned, one cosmetic decomposition difference:

FieldWidthsqueezeplaygo-udap
Dst broadcast + type2 bytesunpackNumber(recv, offset, 2) then & 0x00FF (Udap.lua:313-315)DstBroadcast (1) + DstType (1) (protocol.go:76-78)
DstAddress6 bytesmatchesmatches
Src broadcast + type2 bytesunpackNumber(recv, offset, 2) (Udap.lua:328)SrcBroadcast (1) + SrcType (1) (protocol.go:79-81)
SrcAddress6 bytesmatchesmatches
Sequence2 bytesmatchesmatches
UDAPType2 bytesmatchesmatches
UCPFlags1 bytematchesmatches
UAPClass4 bytesmatchesmatches
UCPMethod2 bytesmatchesmatches

Total: 27 bytes — identical size and identical bytes on the wire.

The structural divergence is purely in how each implementation decomposes the broadcast-flag-plus-type byte pair: squeezeplay reads it as one 2-byte word and masks; go-udap uses two explicit 1-byte struct fields. Same bytes, cleaner read on our side.

UCP method enum (squeezeplay ucpMethods, Udap.lua:56-69):

#Namego-udap
1discoverMethodDiscover
2get_ipMethodGetIP
3set_ipnot implemented (squeezeplay names it but doesn't implement either)
4resetMethodReset
5get_dataMethodGetData
6set_dataMethodSetData
7errorMethodError
8credentials_errorMethodCredentialsError
9adv_discoverMethodAdvDisc
10(unused)
11get_uuidnot implemented
12set_volumenot implemented (player control, out of scope)
13pausenot implemented (player control, out of scope)

Of the three methods squeezeplay defines that go-udap doesn't:

  • get_uuid (0x000b) has real value as a fallback — see section 3.
  • set_volume and pause are inter-device player-control messages, outside the NVRAM-configuration scope of go-udap.

TLV codec: Both use 1-byte type, 1-byte length, N-byte value. Identical format. squeezeplay's parseDiscover (Udap.lua:191-202) and go-udap's parseDiscoveryResponse (discovery.go:114-149) are structurally equivalent.

2. Discovery TLVs and decoding

squeezeplay's ucpCodes (Udap.lua:73-86, 1-indexed) vs. go-udap's constants:

Codesqueezeplay namego-udap const
0x02nametlvDeviceName (discovery.go:59)
0x03typetlvDeviceType (discovery.go:60)
0x04use_dhcpnot decoded (silently ignored as unknown)
0x05ip_addrtlvIPAddr (getip.go:122)
0x06subnet_masktlvSubnetMask (getip.go:123)
0x07gateway_addrtlvGatewayAddr (getip.go:124)
0x09firmware_revtlvFirmwareRev (discovery.go:61)
0x0ahardware_revtlvHardwareRev (discovery.go:62)
0x0bdevice_idtlvDeviceID (discovery.go:63)
0x0cdevice_statustlvDeviceStatus (discovery.go:64)
0x0duuidtlvUUID (discovery.go:65)

TLV 0x04 use_dhcp appears in squeezeplay's name table but is never emitted by any squeezeplay create* function and is absent from configSettings. It may be a legacy code from older simple-discover responses. go-udap correctly ignores it (unknown TLVs fall through to the default: branch with a debug log).

squeezeplay's parseDiscover is reused for three methods: discover, get_ip, AND adv_discover (ucpMethodHandlers, Udap.lua:289-298) — the same TLV decoder regardless of method. go-udap has separate parseDiscoveryResponse and parseGetIPResponse — functionally equivalent but more explicit.

3. get_ip / network-config operation

squeezeplay implements get_ip (method 0x0002) in both roles:

  • Request sender: createGetIPAddr (Udap.lua:428-432) — 27-byte header only, no payload. Matches go-udap's CreateGetIPPacket (getip.go:18-33).
  • Response producer (squeezeplay acting as a device): createGetIpResponse (Udap.lua:562-568) puts only TLV 0x05 (ip_addr) in the response. It does not include 0x06 (subnet_mask) or 0x07 (gateway_addr).

go-udap's parseGetIPResponse correctly handles all three codes (getip.go:98-110), soft-failing on missing ones. squeezeplay's omission of 0x06/0x07 is squeezeplay-as-server behaviour — a real Squeezebox hardware device returns all three. go-udap's decoder is correct.

SetupSqueezeboxApplet reads the get_ip response field pkt.ucp["ip_addr"] (TLV 0x05) to display the device's IP during setup (SetupSqueezeboxApplet.lua:1083-1098). Same field go-udap surfaces as NetworkConfig.IP.

4. NVRAM parameter table (configSettings)

squeezeplay's 22-parameter table (Udap.lua:22-45) vs. go-udap's 26-parameter udap.Parameters:

OffsetLengthsqueezeplay namego-udap name
41lan_ip_modelan_ip_mode
54lan_network_addresslan_network_address
94lan_subnet_masklan_subnet_mask
134lan_gatewaylan_gateway
1733hostnamehostname
501bridgingbridging
521interfaceinterface
594primary_dnsprimary_dns
674secondary_dnssecondary_dns
714server_addressserver_address
794slimserver_addresslms_address (alias slimserver_address)
8333(absent)squeezecenter_name
1731wireless_modewireless_mode
18333SSIDwireless_SSID
2161channelwireless_channel
2181region_idwireless_region_id
2201keylenwireless_keylen
22213wep_keywireless_wep_key
23513(absent)wireless_wep_key_1
24813(absent)wireless_wep_key_2
26113(absent)wireless_wep_key_3
2741weponwireless_wep_on
2751wpa_cipherwireless_wpa_cipher
2761wpa_modewireless_wpa_mode
2771wpa_enabledwireless_wpa_on
27864wpa_pskwireless_wpa_psk

All offsets and lengths match exactly. go-udap adds the wireless_ prefix to wireless parameter names for clarity; squeezeplay uses short names.

go-udap's four extra parameters (squeezecenter_name, wireless_wep_key_{1,2,3}) are present in Net::UDAP. squeezeplay's setup flow simply doesn't write them. The WEP-1/2/3 slots are legitimate NVRAM positions for the secondary WEP key configurations; squeezeplay only writes the primary slot at offset 222.

Canonical-name divergence at offset 79: squeezeplay's slimserver_address is lms_address in go-udap. The alias is registered in parameterAliases, so a config file produced by a squeezeplay-aware tool that uses slimserver_address=... will be accepted by go-udap set --config FILE. go-udap read emits the canonical lms_address.

5. Broadcast and addressing

go-udap always sends to 255.255.255.255:17784 (transport.go:60-67). Never directed-subnet broadcast, never unicast. This is intentional: unconfigured devices (source IP 0.0.0.0) only respond to limited broadcasts. See the post-Phase-6 spike result in docs/superpowers/plans/2026-05-13-getip-hwrev-uuid-iface.md.

squeezeplay does the same. Discovery, set_data, reset, get_uuid, and get_ip requests all go to "255.255.255.255" (default port = 17784). The UdapControlApplet response path (squeezeplay as a device replying to a remote configurator) unicasts the reply to the requester's source IP/port — standard UDAP device behaviour.

Multi-NIC awareness: squeezeplay uses a single shared SocketUdp singleton (_instance enforced in Udap.lua:__init). It has no per-interface logic. go-udap's NewClientForInterface and NewClientForAllInterfaces (with IP_BOUND_IF / SO_BINDTODEVICE plumbing) are go-udap enhancements beyond squeezeplay's scope, reflecting that go-udap runs on developer hosts that often have Wi-Fi + Ethernet both up.

6. Retry and timeout strategy

squeezeplay (SetupSqueezeboxApplet):

  • Every UDAP send transmits the same packet three times in immediate succession (t_udapSend, SetupSqueezeboxApplet.lua:947-949). No delay between sends. This is the explicit retry mechanism — squeezeplay expects to operate over ad-hoc Wi-Fi during device setup, where packet loss is high.
  • Task scheduler with SETUP_TIMEOUT = 45s for normal ops, SETUP_EXTENDED_TIMEOUT = 180s for firmware operations.
  • Reset is special-cased: after 10 failed response polls, the applet assumes the device has rebooted and proceeds (SetupSqueezeboxApplet.lua:1004-1005).
  • Sequence numbers seeded with math.random(65535) and incremented per send; stale-seqno replies are discarded.

go-udap:

  • Sends once. No automatic retransmit.
  • Context-based timeout, default 5 seconds (--timeout configurable).
  • Reset treats context.DeadlineExceeded as success (config.go:188-190).

The divergence is intentional: go-udap's use case is wired/reliable LAN discovery, not ad-hoc Wi-Fi setup. Triple-send on every request would be noise. An optional retry flag could be added if go-udap ever needs to operate over a lossy link.

7. Error handling

squeezeplay's parseUdap (ucpMethodHandlers, Udap.lua:289-298) maps both error (method 7) and credentials_error (method 8) to nil — no handler runs. The calling applet (t_udapSink, SetupSqueezeboxApplet.lua:1036-1100) has no explicit branch for those methods either: error responses fall through silently, the task timer eventually expires, and setup fails with whatever self.errorMsg was last assigned.

go-udap explicitly decodes MethodError (0x0007), extracts the TLV error-message string (TLVTypeErrorMessage = 0x03), and surfaces it to the caller. MethodCredentialsError (0x0008) returns a distinct "rejected credentials" error. This is more correct than squeezeplay's silent-drop pattern.

8. Device-type coverage (the non-SBR question)

The UDAP wire protocol is device-agnostic.

jive/net/Udap.lua has zero device-type branching. The places squeezeplay filters by device type are setup-wizard UI constraints, not protocol constraints:

  • SlimDiscoveryApplet.lua:154: filters adv_discover responses to "squeezebox", "fab4", or "baby".
  • SetupSqueezeboxApplet.lua:220: filters to "squeezebox" only.

These strings map to TLV 0x03 device_type values returned by the hardware:

  • "squeezebox" — the original Squeezebox hardware family (SB2, SB3, Transporter, Receiver, Boom).
  • "fab4" — Squeezebox Touch (TLV 0x0b device_id = 08).
  • "baby" — Squeezebox Radio (TLV 0x0b device_id = 09).

The filtering exists purely to drive the setup-wizard UI — "only show devices awaiting initial setup". Once a device has been onboarded, the protocol is uniform across types.

UdapControlApplet (which handles inbound UDAP on a squeezeplay device) makes no device-type distinction at all.

go-udap's productNameByID map (discovery.go:74-85) lists Squeezebox 2 (02), 3 (03), Transporter (04), SoftSqueeze (05), Boom (06), Receiver (07), Touch (08), Radio (09), Controller (0a), Squeezeslave (0b). The wire protocol and the NVRAM offsets are the same set across all of these.

Practical caveat: go-udap has only been tested against a real Squeezebox Receiver. Behaviour against Boom, Touch, Radio, or Controller is theoretical until someone runs go-udap discover --info and read --all against one. NVRAM values may differ across models (different default firmwares, model-specific feature bits) but the wire framing and parameter offsets/lengths should hold.

9. Findings that motivated new tracker items

Captured separately as TaskCreate entries; cross-referenced here for the sake of the contributor reading this note in isolation:

  • get_uuid (method 0x000b) as a fallback when adv_discover's TLV 0x0d is missing or all-zeros. squeezeplay's SlimDiscoveryApplet does this at line 162-165. Worth implementing in go-udap for the benefit of older firmware that doesn't include UUID in the discovery reply.
  • Hardware verification against non-SBR devices (Boom, Touch, Radio). No code change anticipated; just a confidence check on the device-agnostic claim.
  • Optional --retries N flag for lossy-link scenarios. Low priority; only matters if go-udap is ever pointed at a device over ad-hoc Wi-Fi.

10. Squeezeplay designs intentionally NOT replicated

  • Singleton SocketUdp instance. Makes sense in an embedded single-process runtime; go-udap's instantiated Client per invocation is the right model for a CLI.
  • pkt.sourceType == 0x0001 check (vs. & 0x00FF). squeezeplay rejects packets where the source-broadcast byte is non-zero. go-udap's explicit 1+1 struct split sidesteps the question.
  • Triple-send on every request. Reasonable for ad-hoc wireless; noisy on wired LAN.
  • Seqno-based response filtering (SetupSqueezeboxApplet.lua:1051-1053). squeezeplay discards replies whose seqno doesn't match the last send. go-udap uses MAC+IP matching (waitForDeviceReply), which correctly rejects stale replies from previous invocations even if a seqno happens to collide.
  • Setup-wizard device-type filter (pkt.ucp.type == "squeezebox"). Applet-level UI constraint, not a protocol requirement. go-udap correctly operates on any device that responds.
  • set_volume and pause methods (0x000c, 0x000d). Inter-device player control over UDAP, not NVRAM configuration. Out of scope for go-udap.

On this page