Skip to content

fix: normalise ZoneGroup to array for single zone group setups#3

Open
rodbegbie wants to merge 1 commit into
r-teller:mainfrom
rodbegbie:fix/groups-not-iterable
Open

fix: normalise ZoneGroup to array for single zone group setups#3
rodbegbie wants to merge 1 commit into
r-teller:mainfrom
rodbegbie:fix/groups-not-iterable

Conversation

@rodbegbie

@rodbegbie rodbegbie commented Apr 5, 2026

Copy link
Copy Markdown

Human writing: Hi there. Was working on a tweak to the plugin, and was getting an "Failed to get devices: groups is not iterable” error when trying to set Global Settings, so this is the fix Claude Code made to address that. (I’ll open a separate PR for the feature work I did)

Summary

  • convertXmlToJson returns a plain object for single XML child elements, only promoting to an array when duplicate keys exist. When a Sonos network has only one zone group, ZoneGroups.ZoneGroup is an object rather than an array, causing for...of to throw "groups is not iterable".
  • Wraps the result with [].concat(...) in both getDeviceLocation and getDevices so iteration always works regardless of the number of zone groups.

Test plan

  • Configure plugin with a Sonos setup that has a single zone group — saving global settings should succeed without "groups is not iterable" error
  • Verify multi-zone setups continue to work normally

🤖 Generated with Claude Code

convertXmlToJson returns a plain object for single child elements,
only promoting to array for duplicates. Wrap with [].concat() so
getDevices/getDeviceLocation always iterate over an array regardless
of how many zone groups are present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rodbegbie rodbegbie marked this pull request as ready for review April 5, 2026 21:37
r-teller added a commit that referenced this pull request May 4, 2026
…Error)

Establishes the pattern that downstream Phase 2 beads (h75.3 getDevices,
h75.4 getFavorites, h75.6 play/pause/setVolume/etc.) use to produce
user-facing error messages.

src/modules/common/sonosErrors.js — new module:

- `SonosError` class with `category` (timeout|network|fault|http|parse|
  unknown) + `context` ({host, port, timeoutMs, faultCode, faultDescription,
  status, cause}). Carries enough info for the translator to produce a
  per-op message without parsing strings.
- `translateSonosError(err, {op, host, port, timeoutSec})` helper. Returns
  a new Error of the form "Failed to <op>: <translated cause>". Original
  error preserved as `.cause` for diagnostics. Categories:
  - timeout → "Timeout while reaching <host>:<port> after <N> seconds"
  - network → "Could not reach <host>:<port>"
  - fault   → "Sonos returned error <code>: <description>"
  - http    → "Sonos returned HTTP <status>"
  - parse   → "Could not parse response from <host>:<port>"
- PR #3 safety net: TypeError matching /is not iterable|Cannot read prop/i
  is translated to "Unexpected response shape from <host>:<port>" — the
  user-visible safety net for any code path that forgot `asArray`. Should
  be unreachable once h75.3 ships, but keeps a regression visible.

src/modules/common/sonosService.js — refactored:
- execute() now throws categorized SonosError instances instead of plain
  Error. Same wire-format messages preserved; downstream consumers get
  programmatic access to the failure category for translation.

Tests: 80 → 101 (added sonosErrors.test.js covering every category + the
safety net + the never-leaks-programmer-artifacts spirit; updated
sonosService.test.js for the new SonosError shape).

All gates green: lint, format:check, build, validate, 101/101 vitest.

Closes: streamdeck-sonoscontroller-h75.7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
r-teller added a commit that referenced this pull request May 4, 2026
CLEANROOM authoring. The load-bearing fix for the prd-what.md §3.3
regression where one-element households surface as
"Failed to get devices: <name> is not iterable".

src/modules/common/sonosController.js — adds:

- `getDevices({setAsPrimary})` walks ZoneGroupState topology returning
  DeviceRecord[]. Every iteration over ZoneGroup, ZoneGroupMember, and
  Satellite goes through `asArray` — the discipline that prevents the
  regression class. Wraps with translateSonosError({op: "get devices"})
  so network/timeout/parse/safety-net failures all surface as
  "Failed to get devices: ..." per prd-what.md §7.7.
- Static `SonosController.getDeviceLocation(member)` extracts host from
  member._attributes.Location URL.
- Private `_memberToRecord(member, isSatellite, setAsPrimary)` produces
  the canonical DeviceRecord shape per data-model.md.

Fixtures: tests/fixtures/sonos/zonegroupstate-{stereo-pair, home-theater,
multi-group}.xml — three new SOAP envelopes covering the AC#2/#3/#4
topologies. The single-group-single-member fixture from h75.2 covers AC#1
(the regression case).

Tests: 101 → 114 (13 new in sonosController.discovery.test.js covering
all 7 ACs: single-group regression, stereo pair, home theater
1+3 satellites, multi-group 4 members across 3 groups, primary marking
on/off, UUID stability, network-failure translation, safety-net
translation, getDeviceLocation host extraction).

Closes: streamdeck-sonoscontroller-h75.3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
r-teller added a commit that referenced this pull request May 5, 2026
…very

Phase 4 (Property Inspector) advances from 8% → 92% in one push.
Plugin is now manually testable end-to-end against a real Sonos
household: type IP → click Save and Connect → see speakers populate
→ bind a key to a speaker → configure per-action toggles.

orw.2: BootstrapAccordeon + BootstrapAccordeonItem reusable
primitives. forceExpanded prop drives initial show/collapse +
aria-expanded; user clicks override via Bootstrap Collapse JS;
reactive watcher syncs prop changes via .show()/.hide(). Listens
to shown.bs.collapse / hidden.bs.collapse to track user-driven
state. provide/inject for accordeonId. Establishes Vue component
testing pattern via @vue/test-utils + jsdom — 11 tests cover
forceExpanded, click toggle, aria, slot, id composition.

orw.10: errorMessage ref + Bootstrap alert-danger inside Global
Settings accordion. Hidden when null (no empty shell). X dismisses;
second failure re-renders. Verbatim message text — no scrubbing
(Phase 2 boundary translation owns the wrapping).

orw.11: src/modules/pi/actionSettingsSchema.js — buildActionSettingsPayload
owns the 16-field per-context schema with action-specific gating.
PR #4 ?? null preserves value 1; base64-decode utility for favorite
metadata round-trip. Allow-list constants exported (DISPLAY_*_ACTIONS,
PLAY_MODE_DEFAULTS, INPUT_SOURCE_DEFAULTS).

orw.12: src/modules/pi/globalSettingsSchema.js — buildGlobalSettingsPayload
with Math.max(1, value) clamp on adjustVolumeIncrement at write site
(HTML min="1" is bypassable in the Stream Deck Electron webview's
v-model.number binding, so input-side validation alone is insufficient).

orw.3: src/components/SonosSelection.vue — speaker dropdown (size=5)
with case-insensitive sort by '<zoneName> (<host>) [🛰️]' label, grey
hint line, free-text filter (label OR UUID, case-insensitive). v-model
UUID + emits selection-saved on change.

orw.5: 4 presentation toggles in PiComponent (Display State Based
Title / Marquee Title / Marquee Album Title / Album Art). Each
gated on per-action allow-list (allow-list constants from
actionSettingsSchema). Auto-save on change.

orw.6: 4 action-specific sections — Play Mode(s) / Input Source(s)
/ Equalizer Target / Sonos Favorite(s). Six play-mode switches with
verbatim labels (Normal, Shuffle No Repeat, Shuffle Repeat One,
Shuffle, Repeat One, Repeat All), three input-source switches,
EQ select (Volume/Bass/Treble), favorites select keyed by URI.
All auto-save.

orw.7: Volume Increment override input (volume-up / volume-down
only). Verbatim labels and helper text per prd-what.md §5.8.
Placeholder reflects current global default. PR #4 ?? null
preserves entered value 1.

orw.8: Global Settings accordion built on orw.2 primitive with
forceExpanded={!isConnected}. Four form fields with verbatim labels
and hint text per prd-what.md §6. Reactive bindings to globalSettings
ref. Math.max(1, value) clamp lives at the saveGlobalSettings write
site (orw.12), not on the input.

orw.9: Save and Connect / Save and Reconnect button. Label flips
on connectionState. Spinner during flight. Disabled when address
empty or in flight. Click handler: SonosController.connect →
Promise.all(getDevices, getFavorites) sharing one controller (so
zoneGroupState memoizes), persists via saveGlobalSettings, builds
speakers picker. Brand-new actions default to discovery seed primary.
Sonos Speakers accordion with embedded SonosSelection bound to
settings.uuid via @selection-saved → saveSettings.

Tests: +180 (210 → 390 total). 13 new test files cover schemas,
primitives, every per-action visibility matrix, PR #3 tolerance
fixtures (single-Move / stereo-pair / home-theater), error path,
button state machine.

Closes: streamdeck-sonoscontroller-orw.2
Closes: streamdeck-sonoscontroller-orw.3
Closes: streamdeck-sonoscontroller-orw.5
Closes: streamdeck-sonoscontroller-orw.6
Closes: streamdeck-sonoscontroller-orw.7
Closes: streamdeck-sonoscontroller-orw.8
Closes: streamdeck-sonoscontroller-orw.9
Closes: streamdeck-sonoscontroller-orw.10
Closes: streamdeck-sonoscontroller-orw.11
Closes: streamdeck-sonoscontroller-orw.12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant