diff --git a/docs/sonos-inventar.md b/docs/sonos-inventar.md
new file mode 100644
index 0000000..fc75664
--- /dev/null
+++ b/docs/sonos-inventar.md
@@ -0,0 +1,48 @@
+# Sonos-Inventar (live, 2026-06-17)
+
+Erhoben zero-dep über die Sonos-HTTP-API (`:1400`) vom Mac (VLAN 1, `10.40.41.82`, en5)
+mit `mcp/sonos/sonos-inv.py --seeds 10.40.40.199`. **Alle 10 Geräte S2** (SWGen=2).
+Sonos hat **keine** API zum WLAN-Umstellen — Migration passiert am Netz, nicht am Gerät.
+
+## Bestand (10 Sonos)
+
+| IP | Modell (Nr.) | Zone | MAC | SW | Wifi | Eth | VLAN | Status |
+|---|---|---|---|---|---|---|---|---|
+| 10.40.40.199 | SYMFONISK Bookshelf (S21) | Arbeitszimmer (L) | `34:7E:5C:32:37:96` | 86.7 | 0 | 1 | 40 | ✅ wired (Omada P6) |
+| 10.40.40.200 | SYMFONISK Bookshelf (S21) | Arbeitszimmer (R) | `34:7E:5C:32:33:B8` | 86.7 | 0 | 1 | 40 | ✅ wired |
+| 10.40.41.193 | SYMFONISK Table lamp (S20) | Schlafzimmer (L) | `78:28:CA:80:2A:F6` | 86.7 | 0 | 1 | 1 | ⏳ **wired** → Port nach VLAN 40 |
+| 10.40.41.194 | SYMFONISK Table lamp (S20) | Schlafzimmer (R) | `78:28:CA:82:16:C2` | 86.7 | 0 | 1 | 1 | ⏳ **wired** → Port nach VLAN 40 |
+| 10.40.41.195 | Sonos One SL (S22) | Bad | `48:A6:B8:14:FE:D0` | 95.0 | 1 | 0 | 1 | ⏳ **WLAN** |
+| 10.40.41.196 | Sonos One SL (S22) | Kinderzimmer | `48:A6:B8:16:38:AE` | 95.0 | 1 | 0 | 1 | ⏳ **WLAN** |
+| 10.40.41.197 | Sonos One SL (S22) | Küche | `48:A6:B8:14:FE:F2` | 95.0 | 1 | 0 | 1 | ⏳ **WLAN** |
+| 10.40.41.199 | **Sonos Arc (S19)** | Wohnzimmer (LF,RF) | `48:A6:B8:0B:63:10` | 95.0 | 1 | 0 | 1 | ⏳ **WLAN** — HT-Coordinator |
+| 10.40.41.200 | **Sonos Amp (S16)** | Wohnzimmer (LR,RR) | `48:A6:B8:69:69:7B` | 95.0 | 1 | 0 | 1 | ⏳ **WLAN** — HT-Surrounds |
+| 10.40.41.201 | **Sonos Sub** | Wohnzimmer (SW) | `94:9F:3E:49:59:EE` | 86.7 | 1 | 0 | 1 | ⏳ **WLAN** — HT-Sub |
+
+Alle DHCP (Sonos kann kein statisches IP); VLAN-40-Paar hat dyn. Leases aus `dhcp-v40`.
+
+## Migrations-Klassen
+1. **Fertig (VLAN 40, wired):** Arbeitszimmer-Paar `.40.199/.200`.
+2. **Wired, nur Port umhängen → VLAN 40:** Schlafzimmer-Table-lamp-Paar `.41.193/.194`
+ (Wifi=0, Eth=1). Kamen via ALLNET/FB-Uplink (nicht direkter Omada-Access-Port) →
+ **Switch-Port lokalisieren** (ALLNET MAC-Tabelle) und auf VLAN-40-Access setzen. = der „das schaff ich"-Teil.
+3. **Echtes WLAN (der Schmerz) — 6 Geräte:**
+ - Standalone: Sonos One SL **Bad / Kinderzimmer / Küche**.
+ - **Home-Theater-Bond „Wohnzimmer": Arc + Amp + Sub** (Arc = Coordinator; Amp/Sub als
+ Satelliten an den Arc gebunden — beim Umzug zusammen behandeln).
+ → Alle **S2** ⇒ `:1400/wifictrl` (Remote-WLAN-Aus) **nicht verfügbar**. Sauberer Weg =
+ **TP3-Cutover** (Media-SSID bzw. Per-MAC → VLAN 40); App-Re-Onboarding vermeidbar, wenn die
+ Media-SSID die Geräte beim FB-WLAN-Abschalten selbst übernimmt. Alternativ kurzzeitig verkabeln.
+
+## Household-Partitionierung (Tooling-Gotcha)
+ZoneGroupState ist **pro VLAN partitioniert** (Multicast quert VLANs nicht):
+VLAN 40 sieht nur `.40.199/.200`, VLAN 1 sieht nur `.41.193–.201`. → Tool braucht **einen Seed
+je Partition** (`10.40.40.199` **und** ein VLAN-1-Gerät; lokales SSDP deckt VLAN 1 automatisch ab).
+
+## Werkzeug
+`mcp/sonos/sonos-inv.py` (zero-dep, kein Daemon). Inventar:
+```
+./mcp/sonos/sonos-inv.py --seeds 10.40.40.199 # Tabelle
+./mcp/sonos/sonos-inv.py --seeds 10.40.40.199 --json # maschinenlesbar
+./mcp/sonos/sonos-inv.py --seeds 10.40.40.199 --verify 40 # Exit!=0 wenn ein Gerät nicht in VLAN 40
+```
diff --git a/mcp/sonos/README.md b/mcp/sonos/README.md
new file mode 100644
index 0000000..0e3947d
--- /dev/null
+++ b/mcp/sonos/README.md
@@ -0,0 +1,23 @@
+# sonos-inv — Sonos-Inventar/Verifikation (zero-dep, KEIN MCP)
+
+Bewusste Entscheidung statt Sonos-MCP-Container: für unseren Zweck (Bestand erheben +
+VLAN-Umzug verifizieren) reicht ein kleines stdlib-Python-Skript über die Sonos-`:1400`-API.
+Ein SoCo-MCP-Container bräuchte einen gepatchten Fork (Multicast-only, kein Seeding, kein
+Image) und je VLAN-Partition einen Seed — Aufwand ohne Mehrwert, solange wir kein
+Playback per Chat steuern. **Sonos hat keine API zum WLAN-Umstellen** — daher hier nicht abgebildet.
+
+## Nutzung
+```
+./sonos-inv.py # nur lokales Segment (SSDP)
+./sonos-inv.py --seeds 10.40.40.199 # + VLAN-40-Partition (BF-Standard)
+./sonos-inv.py --seeds 10.40.40.199 --json # maschinenlesbar
+./sonos-inv.py --seeds 10.40.40.199 --verify 40 # Exit!=0 wenn ein Gerät nicht in VLAN 40
+```
+Von einem **VLAN-1-Host** ausführen (z.B. Mac `10.40.41.82`); inter-VLAN-L3 ist offen (bis TP5),
+daher sind die VLAN-40-Geräte per Seed-IP erreichbar.
+
+## Gotcha — Discovery ist pro VLAN partitioniert
+SSDP-Multicast quert keine VLANs → ein Seed **je Partition** nötig. Lokales SSDP deckt das
+Segment des ausführenden Hosts ab (VLAN 1), `--seeds 10.40.40.199` zieht die VLAN-40-Partition dazu.
+
+Bestand & Migrationsplan: `../../docs/sonos-inventar.md`.
diff --git a/mcp/sonos/sonos-inv.py b/mcp/sonos/sonos-inv.py
new file mode 100755
index 0000000..6af3fa2
--- /dev/null
+++ b/mcp/sonos/sonos-inv.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""sonos-inv — zero-dep Sonos-Inventar & Verifikation über die :1400-HTTP-API.
+
+KEIN MCP/Daemon, KEINE Fremd-Libs (nur Python-stdlib). Ersetzt für unseren Zweck
+(Inventar + Umzugs-Verifikation) einen Sonos-MCP-Container. Sonos hat KEINE API zum
+WLAN-Umstellen — das ist hier bewusst nicht abgebildet.
+
+WICHTIG — Discovery ist bei Sonos pro L2/VLAN **partitioniert** (SSDP-Multicast quert
+keine VLANs). Darum: SSDP auf dem lokalen Segment + optional **ein Seed je weiterer
+Partition**. Bei BF: lokal (VLAN 1) + Seed 10.40.40.199 (VLAN 40).
+
+Usage:
+ ./sonos-inv.py # nur lokales Segment (SSDP)
+ ./sonos-inv.py --seeds 10.40.40.199 # + VLAN-40-Partition
+ ./sonos-inv.py --seeds 10.40.40.199,10.40.41.193 # explizit beide Partitionen
+ ./sonos-inv.py --json # maschinenlesbar
+ ./sonos-inv.py --verify 40 # Exit!=0 wenn ein Gerät NICHT in VLAN 40
+
+Felder je Gerät: ip, vlan(rateil aus 3. Oktett), model, zone, mac, swgen(1=S1/2=S2),
+sw, wifi(WifiEnabled), eth(EthLink).
+"""
+import argparse, html, json, re, socket, sys, urllib.request
+
+TIMEOUT = 5
+SOAP_BODY = (
+ ''
+ ''
+ ''
+)
+
+def _get(ip, path, retries=2):
+ for _ in range(retries):
+ try:
+ return urllib.request.urlopen(f"http://{ip}:1400{path}", timeout=TIMEOUT).read().decode(errors="replace")
+ except Exception:
+ continue
+ return ""
+
+def ssdp_discover(timeout=3):
+ """SSDP M-SEARCH nach ZonePlayern auf dem lokalen Segment → set(IPs)."""
+ msg = "\r\n".join([
+ "M-SEARCH * HTTP/1.1", "HOST: 239.255.255.250:1900", 'MAN: "ssdp:discover"',
+ "MX: 2", "ST: urn:schemas-upnp-org:device:ZonePlayer:1", "", "",
+ ])
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
+ s.settimeout(timeout)
+ found = set()
+ try:
+ s.sendto(msg.encode(), ("239.255.255.250", 1900))
+ import time
+ t = time.time()
+ while time.time() - t < timeout + 0.5:
+ try:
+ _, addr = s.recvfrom(2048)
+ found.add(addr[0])
+ except socket.timeout:
+ break
+ finally:
+ s.close()
+ return found
+
+def zone_group_members(ip):
+ """GetZoneGroupState an einem Gerät → {ip: {attr: val}} aller Member dieser Partition."""
+ req = urllib.request.Request(
+ f"http://{ip}:1400/ZoneGroupTopology/Control", data=SOAP_BODY.encode(),
+ headers={"Content-Type": "text/xml; charset=utf-8",
+ "SOAPACTION": '"urn:schemas-upnp-org:service:ZoneGroupTopology:1#GetZoneGroupState"'})
+ try:
+ d = urllib.request.urlopen(req, timeout=TIMEOUT).read().decode(errors="replace")
+ except Exception:
+ return {}
+ m = re.search(r"(.*)", d, re.S)
+ body = html.unescape(m.group(1)) if m else d
+ members = {}
+ for tag in re.finditer(r"<(?:ZoneGroupMember|Satellite)\s+([^>]*?)/?>", body):
+ attrs = dict(re.findall(r'(\w+)="([^"]*)"', tag.group(1)))
+ loc = attrs.get("Location", "")
+ mip = re.search(r"http://([\d.]+):", loc)
+ if mip:
+ members[mip.group(1)] = attrs
+ return members
+
+def device_detail(ip):
+ """Modell + MAC aus device_description/status/zp (was ZGS nicht liefert)."""
+ dd = _get(ip, "/xml/device_description.xml")
+ zp = _get(ip, "/status/zp")
+ def g(pat, src):
+ mm = re.search(pat, src)
+ return mm.group(1) if mm else ""
+ return {
+ "model": g(r"(.*?)", dd),
+ "modelnum": g(r"(.*?)", dd),
+ "mac": g(r"(.*?)", zp).split(":")[0], # "AA-BB-..-XX:3" → MAC
+ }
+
+def vlan_of(ip):
+ o = ip.split(".")
+ if len(o) == 4 and o[0] == "10" and o[1] == "40":
+ return {"10": "10", "20": "20", "30": "30", "40": "40", "50": "50",
+ "90": "90", "99": "99", "41": "1/legacy"}.get(o[2], o[2])
+ return "?"
+
+def inventory(seeds):
+ candidates = set(ssdp_discover()) | set(seeds)
+ members = {}
+ for ip in sorted(candidates):
+ if ip in members:
+ continue
+ members.update(zone_group_members(ip))
+ rows = []
+ for ip in sorted(members, key=lambda x: tuple(int(p) for p in x.split("."))):
+ a = members[ip]
+ det = device_detail(ip)
+ rows.append({
+ "ip": ip, "vlan": vlan_of(ip),
+ "model": f"{det['model']} ({det['modelnum']})".strip(),
+ "zone": a.get("ZoneName", ""), "mac": det["mac"],
+ "swgen": a.get("SWGen", ""), "sw": a.get("SoftwareVersion", ""),
+ "wifi": a.get("WifiEnabled", ""), "eth": a.get("EthLink", ""),
+ })
+ return rows
+
+def main():
+ ap = argparse.ArgumentParser(description="Sonos-Inventar/Verifikation (zero-dep, :1400)")
+ ap.add_argument("--seeds", default="", help="Komma-IPs, je 1 pro weiterer VLAN-Partition (z.B. 10.40.40.199)")
+ ap.add_argument("--json", action="store_true")
+ ap.add_argument("--verify", metavar="VLAN", help="Exit!=0 wenn ein Gerät NICHT im genannten VLAN ist")
+ args = ap.parse_args()
+ seeds = [s.strip() for s in args.seeds.split(",") if s.strip()]
+ rows = inventory(seeds)
+ if args.json:
+ print(json.dumps(rows, indent=2, ensure_ascii=False))
+ else:
+ h = f"{'IP':15} {'VLAN':9} {'Modell':28} {'Zone':18} {'MAC':18} {'Gen':3} {'SW':14} {'Wifi':4} {'Eth':3}"
+ print(h); print("-" * len(h))
+ for r in rows:
+ print(f"{r['ip']:15} {r['vlan']:9} {r['model']:28.28} {r['zone']:18.18} {r['mac']:18} "
+ f"{r['swgen']:3} {r['sw']:14} {r['wifi']:4} {r['eth']:3}")
+ print(f"\n{len(rows)} Sonos. (Discovery pro VLAN partitioniert — Seeds: {seeds or 'nur lokal'})")
+ if args.verify:
+ bad = [r["ip"] for r in rows if r["vlan"] != args.verify]
+ if bad:
+ print(f"VERIFY FAIL: nicht in VLAN {args.verify}: {bad}", file=sys.stderr)
+ sys.exit(1)
+ print(f"VERIFY OK: alle {len(rows)} in VLAN {args.verify}")
+ return 0
+
+if __name__ == "__main__":
+ sys.exit(main())