feat 🔌 TP17 GO: 16 Shellys VLAN1→VLAN30 (Anker-Verfahren) + eco-Policy; TP17.2/TP19 angelegt

- migrate.py: anker-sicherer Runner (AP-Anker vor sta-Write, deanchor, auth, table)
- 16 Shellys migriert (Pilot+2 Wellen), 22/26 auf VLAN30, VLAN1 shelly-frei
- eco-Policy RSSI-basiert (AN >= -80, AUS < -80), provisorisch bis TP3.3
- bf.csv: 22 Shelly-Zeilen .41→.30, Section→IOT
- TP17.2 (Geräte-PW, zurückgestellt/Kosmetik) + TP19 (cudy-Teardown + VLAN1-Retirement) angelegt
- Verlorene Küchen-Shelly via HA identifiziert (Plug S Gen3, e4:b0:63:e4:1a:b8, bogus-IP 10.95.10.206 auf elektrosmog.c) → Remote-Recovery-Plan in TP19

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:27:56 +02:00
parent 850f0c7560
commit 2135772e04
3 changed files with 311 additions and 23 deletions

View File

@@ -353,7 +353,18 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung".
- **Umsetzungs-Reihenfolge (für die eigene Session):** (1) **Reachability-Matrix je Host** (pve/NAS/macmini → jede Origin, read-only) BEVOR gruppiert wird; (2) **Interim-Sofortfix:** bf-Tunnel auf **pve-only** (pve erreicht alles außer gateway-dw) → 0×1033, reversibel; (3) reziprokes 3-Tunnel-HA bauen (CNAMEs re-pointen, 6 Connectoren, Token je Tunnel; NAS/macmini-Alt-Token entfernen); (4) tote Origins bereinigen; (5) Access-Strang (WARP + vw); (6) Loop-Gegentest.
- Abh.: TP14.1/14.2 ✅. Verwandt: **TP11** (CF-Tunnel-Track-B/C + Access), #1033. SSoT: `docs/cf-tunnel-mapping.md`. Memory `project_cf_tunnel_1033`.
### TP17 — Shelly-Übersiedelung VLAN1 → VLAN30 (IOT) + `shelly-mcp` ▸ ⏳ WIP (go-ready, ~5/26 migriert) — `mcp/shelly/`, Runbook `docs/tp17-shelly-go-runbook.md`
### TP17 — Shelly-Übersiedelung VLAN1 → VLAN30 (IOT) + `shelly-mcp` ▸ ✅ WLAN+IP DURCH (22/26, 2026-07-13); Geräte-PW + cudy-Teardown offen — `mcp/shelly/`, Runbook `docs/tp17-shelly-go-runbook.md`
- ✅ **GO durchgezogen 2026-07-13 (16 Shellys VLAN1→VLAN30, 0 Fehler):** Pilot s26 + Welle A (s4–s9) + Welle B (s10/11/13/15/17/19/20/21/25) → alle auf `dirtyvader-iot`/`10.40.30.<octet>` verifiziert (SSID+IP+rssi). Mit den 6 Vorab-Migrierten (s14/16/18/22/23/24) = **22/26 auf VLAN30**; VLAN1-Sweep `.180-250` = **0 Shellys**. Offen nur 4 saisonal ausgesteckte (s1/s2/s3/s12 → beim Einstecken).
- 🔧 **Anker-Verfahren (User-Idee, statt sta1-Backup):** je Gerät **vor** dem sta-Write die geräteeigene **AP-Funktion** aktiviert (`WiFi.SetConfig ap.enable` — Firmware verlangt WPA2, kein offenes AP → PSK=IOT-PSK, live/kein Reboot) = Notanker, falls VLAN30-Join scheitert (Gerät bleibt über `Shelly*-<mac>`-AP erreichbar, **kein physischer Reset**). Nach Verify alle 22 AP-Anker wieder `deanchor`t. Runner: `mcp/shelly/migrate.py` (`migrate`/`deanchor`/`auth`/`table`, Dry-Run default, stoppt bei erstem Fehler, reused `discover.py`).
- ✅ **HA-Cross-Check nach Batch:** einzige `unavailable` Shelly-Entities = **s12** (saisonal aus) — alle 22 migrierten reconnecten via mDNS/zeroconf (TP7-Repeater vlan30↔vlan20-HA) auf neuer `.30.x`-IP, **Entity-IDs unverändert** (keyt per MAC). Inventar `inventory/bf.csv` .41→.30 nachgezogen (22 Zeilen, Section→IOT).
- ⚠️ **Coverage-Flag:** `s4 kueche_mikrowelle` verbindet mit **rssi −93** (Metallgehäuse), hat aber verifiziert — grenzwertig, im Auge behalten. s7 Tiefkühl −85. Rest gut (−26…−81).
- ✅ **Eco-Mode-Policy (2026-07-13, PROVISORISCH):** eco senkt CPU-Takt + WiFi-TX-Leistung → **eco AN für RSSI ≥ −80** (Energie-Win, TX irrelevant), **eco AUS für < −80** (Uplink-Headroom): s4/−91, s7/−88, s17/−81, s18/−79…−82, s19/−86 → 17× ON / 5× OFF, live via `Sys.SetConfig device.eco_mode` (kein Reboot). s4 (Küchen-Sockel/Metall, −91, fix) bewusst eco-OFF = schwächster Link. ⏳ **2. Eval nötig nach TP3.3** (WLAN-Ausrichtung/Aufbau ändert RSSI → Grenzfälle neu bewerten; User 2026-07-13).
- ✅ **HA-Referenz http vs https geklärt (2026-07-13):** HA speichert `configuration_url` für **alle 22 Shellys bereits `http://10.40.30.x:80`** (Integration korrekt + neue IPs übernommen). Das „https" ist **client-seitig** (Mixed-Content-Upgrade, wenn HA selbst über https/Tunnel geöffnet) — **kein HA-Fix möglich/nötig, Shelly-SSL bewusst NICHT** (CPU/Energie, IoT-Netz). ⚠️ Rand-Fund: 1 HA-Shelly-Device zeigt auf `10.95.10.206` (Fremd-Subnetz, nicht BF) → separat prüfen.
- ⏳ **NOCH OFFEN:** (1) einheitliches Geräte-PW → **zurückgestellt als [[#TP17.2]]** (Kosmetik, User 2026-07-13). (2) Nicht-Shelly-Mover + cudy-Teardown + VLAN1-Client-Retirement → **eigener aktiver TP19** (nächste Priorität, User 2026-07-13). (3) DNS/ip-plan Restfeinschliff (mit TP19).
#### TP17.2 — einheitliches Geräte-Passwort (Shelly `SetAuth`) ▸ ⏸️ ZURÜCKGESTELLT (Kosmetik, User 2026-07-13)
- **Was:** je Gerät `Shelly.SetAuth` (User `admin`, realm-individueller `ha1` = sha256("admin:<device-id>:<pw>"), einheitliches Klartext-PW) via `mcp/shelly/migrate.py auth --go`. `SHELLY_DEVICE_PW` liegt in `mcp/shelly/.env` (gesetzt, len 13). Runner + Digest-Auth-Fallback in `_rpc` fertig.
- **Warum zurückgestellt:** rein kosmetisch/Härtung; **bricht die HA-Shelly-Integration**, bis pro Config-Entry (22×) das Credential neu eingetragen ist → disruptiv, kein Mehrwert im abgeschotteten IOT-VLAN. **Erst nach** dem Netz-Teardown (TP19) neu bewerten.
- **Ziel:** ~26 Shellys (aktuell untagged VLAN1 `10.40.41.2xx`, weil auf **alt-WLAN cudy**, s. TP3.1/`project_vlan1_dhcp_incident`) auf SSID **`dirtyvader-iot`** (VLAN30, `w2-iot`, WPA2/WPA3-mixed, PSK `74c9L283$669`) umklemmen → DHCP `dhcp-v30`/pool-v30 (live). **Kein netzseitiger Move möglich** (anderes WLAN) → per-Gerät WiFi-Reconfig.
- **User-Entscheide (2026-07-11):** (1) **volles `shelly-mcp`** bauen (reusable, nicht nur Skript); (2) **Pilot 2–3, dann Rest**; (3) **DHCP-Reservierungen je MAC** in VLAN30 (stabile IPs für HA/ioBroker/DNS).
- ✅ **Recon durch (`mcp/shelly/discover.py`, read-only /shelly-Sweep):** 16 live, **alle Gen3/Gen4** (uniforme RPC-API `WiFi.SetConfig`, auth=off — kein Gen1-Sonderfall). 10 „offline" = **weihnachten_\*** (s1/s2/s3, saisonal ausgesteckt Juli) + s12/s14/s16/s18/s22/s23 (aus/DHCP-drift). ⚠️ **Inventar-Fix:** s6 (kueche_kuehlschrank, `.213`) MAC falsch erfasst (`0c:72:74:36:90:9e`) → real **`d0:cf:13:d8:18:fc`**. s24 hat gar keine MAC.
@@ -382,6 +393,21 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung".
- **Offen (Modell scharfstellen):** realer Strompreis; ob **FM/DW eigene Nodes stellen** oder Christoph HW hinstellt; Backup-Tool-Wahl (restic vs Kopia); ob heißes Zeug künftig auf ZFS/pve statt Syno.
- Abh.: **TP10** (WG-Multi-Site = Transport, Upload-BW-Constraint), **TP14** (Container-Hosts), **TP15** (Node-HW/USV), Muster von **TP16** (reziprokes Per-Host-HA). Kein Blocker für laufende TPs. SSoT dieser Session.
### TP19 — cudy-Teardown + VLAN1-Client-Retirement ▸ 🔜 AKTIV (nächste Priorität, User 2026-07-13)
- **Ziel (Kapstein der Segmentierung, s. Zeile 27):** altes **cudy-WLAN weg** + **VLAN1 als Client-Netz stilllegen** (`defconf`/pool-v1-Client-DHCP zurückziehen). **Nicht** VLAN1 komplett: Switch-/AP-Mgmt (sw1 `.11`/sw2 `.12`/ap2 `.2`) bleiben **by design** in VLAN1 (statisch, kein DHCP nötig) bis zum separaten **MGMT-VLAN99-Cutover** (eigener TP). cudy1/cudy2 = `.41.246`/`.247` (Teardown-Ziele).
- 🎯 **User-Gate (2026-07-13): sobald die verlorene Shelly + SwitchBot laufen → VLAN1 + cudy-WLAN außer Betrieb.** Twinkly nebenher (saisonal).
- **Verlorene Shelly — IDENTIFIZIERT via HA 2026-07-13** (User-Hinweis „du siehst ihn im HA"): HA-Device `shellyplugsg3-e4b063e41ab8`, **Modell = Shelly Plug S Gen3**, **MAC `e4:b0:63:e4:1a:b8`**, HA-device_id `7a21e8f3349e6660f8b35974fc989a7b`. **NICHT im 26er-Inventar** (Spare/Küchen-Steckdose, die Christoph beim Einrichten verbockt hat; er nannte sie „küche s20", aber s20=`98:a3:16:7c:5f:ec` läuft sauber auf `.30.206` — Verwechslung). **KEIN AP-Mode, nicht tot:** assoziiert am **Altnetz `elektrosmog.c`** (cudy → untagged VLAN1-L2), aber mit **bogus-IP `10.95.10.206`** (Fremd-Subnetz, User-Fehler) → unrouting-bar, daher weder in VLAN1/30-Sweep noch normal per IP erreichbar, aber **per MAC in HA/WiFi-Table sichtbar**. Der `.219/s12`-Verdacht war Sackgasse (s12 nur saisonal aus).
- **Recovery-Plan (remote, für neue Session, NACH SwitchBot — User-Reihenfolge):** ap1 bekommt **temporäre Zweit-IP `10.95.10.1/24` auf der Bridge** → `10.95.10.206` per L2 erreichbar → **ein** `WiFi.SetConfig {sta: ssid=dirtyvader-iot, pass=IOT-PSK, static, ip=10.40.30.<frei>}` + `Shelly.Reboot` → Gerät kommt auf VLAN30 hoch → temp-IP entfernen. Danach `.30.x`-Reservierung + Inventar (MAC `e4:b0:63:e4:1a:b8`) + eco-Policy. Umgeht die „blutige Nase" (IP am Gerät ändern während man drauf hängt = Verbindungsverlust) — vom Router aus in einem Rutsch. Christophs manueller Direkt-Versuch (AP-IP auf 10.40.30 „verdrehen") war genau deshalb gescheitert.
- **SwitchBot floor-lamp** `.41.184` (`b8:f8:62:09:f2:04`, live auf VLAN1/cudy) → **VLAN30 via SwitchBot-App** (kein Local-WiFi-API, nur App/BLE-Provisioning), Res. `.30.227` liegt. = Christoph-App-Task.
- ⚠️ **Prereqs — restliche VLAN1-Clients umziehen, SONST verwaisen sie beim cudy-Tod** (live ap1-ARP 2026-07-13):
1. **Christophs Endgeräte → VLAN10** (`dirtyvader-main`): MacBook-Air `.41.182` (`c0:c7:db:de:71:be` — **= Claude-Sandbox-Client!** vor cudy-Abschuss umhängen, sonst Zugangs-Verlust), iPhone `.41.186` (aktuell off/asleep).
2. **2 UNIDENTIFIZIERTE VLAN1-Geräte (2026-07-13):** `.41.133` (`02:11:32:22:1e:25`, lokal-administrierte/randomisierte MAC — Phone-Random? VM?) + `.41.198` (`34:2b:6e:6f:1f:ef`) → **vor Teardown identifizieren + verorten.**
3. **Media → VLAN40 verifizieren:** Apple TV 4K + Philips TV 50PUS7304 (beide **aus**) einschalten, prüfen VLAN40 vs cudy/VLAN1. ⚠️ **Apple TV = vmtl. Thread-Border-Router** der 22-Matter-Flotte → nicht sorglos stromlos (TP17.1).
4. **Saisonale Shellys s1/s2/s3/s12** (aus): nach cudy-Tod zeigt `sta` auf tote cudy-SSID → beim Einstecken AP-Mode statt Join. **Empfehlung: vor cudy-Abschuss** kurz einstecken + `migrate.py`, sonst AP-Onboarding später.
5. **pve-Interim-IP `.41.100`** (bewusst als Fallback belassen) beim Teardown mit abräumen. Streu-Reste final per ARP/DHCP-Sweep auf 0.
- **Teardown-Sequenz (nach Prereqs):** VLAN1-Client-Sweep = leer (nur Mgmt bleibt) → cudy1/cudy2 stromlos/Reset/ausbauen → ap1 `defconf`/pool-v1-Client-DHCP zurückziehen (VLAN1-Mgmt-Reachability für sw1/sw2/ap2 erhalten) → Interim-Krücken aufräumen (ap2 `frame-types=admit-only-vlan-tagged` = auto-weg mit VLAN1; `st-main rrm/wnm` Roaming-Provisorium neu bewerten) → Inventar/`ip-plan`/DNS `.41→Ziel` final.
- Abh.: **TP14 ✅** (Hosts raus aus VLAN1), **TP17 ✅** (Shellys raus). Verwandt: `project_vlan1_dhcp_incident` (cudy-Dual-DHCP-Leak), **VLAN99-MGMT-Cutover** (Folge-TP, entkoppelt), TP5 (Deny-by-default danach sauberer). SSoT hier + Runbook.
### TP-R — Router-Capabilities-Sweep ▸ FERTIG (`docs/research/routeros-capabilities.md`)
- **Leitsatz: nativ vor Container, NAS vor Router** (128MB-Flash-Limit).
- **Sofort nativ (~0 RAM):** RoMON · Netwatch · Scheduler + Auto-Backup/Mail · SNMP→externes Monitoring · **mDNS-Repeater** · spectral-scan · **CAPsMAN + 802.11r/k/v (Fast-Roaming statt Mesh)** · Back-to-Home/WireGuard.

View File

@@ -199,38 +199,38 @@ Sonos,10.40.41.198,SonosTerrasse,48:a6:b8:16:38:ae,,,,"dhcp-host=48:a6:b8:16:38:
Sonos,10.40.41.199,SonosWohnzimmer1,48:A6:B8:0B:63:10,,,,"48:A6:B8:01:13:EE / dhcp-host=48:A6:B8:0B:63:10,10.40.41.199,SonosWohnzimmer1"
Sonos,10.40.41.200,SonosWohnzimmer2,48:a6:b8:14:fe:f2,,,,"dhcp-host=48:a6:b8:14:fe:f2,10.40.41.200,SonosWohnzimmer2"
Sonos,10.40.41.201,SonosWohnzimmer3,78:28:ca:d2:0d:ac,,,,"dhcp-host=78:28:ca:d2:0d:ac,10.40.41.201,SonosWohnzimmer3"
Sonos,10.40.41.202,shelly: s22,a0:85:e3:c8:65:34,,,,kueche_steckdose3-2
Sonos,10.40.41.203,shelly: s23,ac:15:18:7b:62:20,,,,dieleug-wohnzimmer_licht
Sonos,10.40.41.204,shelly: s24,14:33:5c:d8:2d:7c,,,,bad_spiegelschrank (Shelly Plus RGBW PM, bereits VLAN30 .30.204)
Sonos,10.40.41.205,shelly: s25,98:a3:16:79:38:50,,,,bad_lichtschalter
IOT,10.40.41.206,shelly: s20,98:a3:16:7c:5f:ec,,,,kueche_steckdosen2
IOT,10.40.41.207,shelly: s21,20:6e:f1:01:f9:f8,,,,kueche_steckdosen4
IOT,10.40.30.202,shelly: s22,a0:85:e3:c8:65:34,,,,kueche_steckdose3-2
IOT,10.40.30.203,shelly: s23,ac:15:18:7b:62:20,,,,dieleug-wohnzimmer_licht
IOT,10.40.30.204,shelly: s24,14:33:5c:d8:2d:7c,,,,bad_spiegelschrank (Shelly Plus RGBW PM
IOT,10.40.30.205,shelly: s25,98:a3:16:79:38:50,,,,bad_lichtschalter
IOT,10.40.30.206,shelly: s20,98:a3:16:7c:5f:ec,,,,kueche_steckdosen2
IOT,10.40.30.207,shelly: s21,20:6e:f1:01:f9:f8,,,,kueche_steckdosen4
IOT,10.40.41.208,shelly: s1,E4:B0:63:D7:C7:A4,,,,weihnachten_kueche
IOT,10.40.41.209,shelly: s2,28:37:2F:36:F4:7C,,,,weihnachten_terrasse
IOT,10.40.41.210,shelly: s3,E4:B0:63:E4:EF:7C,,,,weihnachten_kinderzimmer
IOT,10.40.41.211,shelly: s4,E4:B0:63:E4:0F:30,,,,kueche_mikrowelle
IOT,10.40.41.212,shelly: s5,D8:85:AC:0C:5C:C8,,,,kueche_backofen
IOT,10.40.41.213,shelly: s6,d0:cf:13:d8:18:fc,,,,kueche_kuehlschrank
IOT,10.40.41.214,shelly: s7,D0:CF:13:D9:94:E8,,,,kueche_tiefkuehlschrank
IOT,10.40.41.215,shelly: s8,E4:B0:63:FA:FD:E0,,,,kueche_geschirrspueler
IOT,10.40.41.216,shelly: s9,E8:F6:0A:61:88:08,,,,wohnzimmer_sideboard
IOT,10.40.41.217,shelly: s10,E4:B0:63:FC:57:E4,,,,bad_sonos
IOT,10.40.41.218,shelly: s11,E4:B0:63:FA:6C:00,,,,kinderzimmer_sonos
IOT,10.40.30.211,shelly: s4,E4:B0:63:E4:0F:30,,,,kueche_mikrowelle
IOT,10.40.30.212,shelly: s5,D8:85:AC:0C:5C:C8,,,,kueche_backofen
IOT,10.40.30.213,shelly: s6,d0:cf:13:d8:18:fc,,,,kueche_kuehlschrank
IOT,10.40.30.214,shelly: s7,D0:CF:13:D9:94:E8,,,,kueche_tiefkuehlschrank
IOT,10.40.30.215,shelly: s8,E4:B0:63:FA:FD:E0,,,,kueche_geschirrspueler
IOT,10.40.30.216,shelly: s9,E8:F6:0A:61:88:08,,,,wohnzimmer_sideboard
IOT,10.40.30.217,shelly: s10,E4:B0:63:FC:57:E4,,,,bad_sonos
IOT,10.40.30.218,shelly: s11,E4:B0:63:FA:6C:00,,,,kinderzimmer_sonos
IOT,10.40.41.219,shelly: s12,48:f6:ee:c8:e6:ac,,,,dieleug_steckdosen
IOT,10.40.41.220,shelly: s13,48:F6:EE:DD:6A:F4,,,,arbeitszimmer_technik
IOT,10.40.41.221,shelly: s14,E4:B0:63:FC:59:48,,,,schlafzimmer_bett
IOT,10.40.41.222,shelly: s15,E4:B0:63:FA:D5:34,,,,technik_pve
IOT,10.40.41.223,shelly: s16,10:20:ba:fa:f5:40,,,,wohnzimmer_steckdosen
IOT,10.40.41.224,shelly: s17,10:20:ba:fb:6d:64,,,,bad_steckdosen
IOT,10.40.41.225,shelly: s18,48:f6:ee:cc:16:e4,,,,kueche_steckdosen1
IOT,10.40.41.226,shelly: s19,10:20:ba:fb:70:fc,,,,kueche_steckdosen3
IOT,10.40.30.220,shelly: s13,48:F6:EE:DD:6A:F4,,,,arbeitszimmer_technik
IOT,10.40.30.221,shelly: s14,E4:B0:63:FC:59:48,,,,schlafzimmer_bett
IOT,10.40.30.222,shelly: s15,E4:B0:63:FA:D5:34,,,,technik_pve
IOT,10.40.30.223,shelly: s16,10:20:ba:fa:f5:40,,,,wohnzimmer_steckdosen
IOT,10.40.30.224,shelly: s17,10:20:ba:fb:6d:64,,,,bad_steckdosen
IOT,10.40.30.225,shelly: s18,48:f6:ee:cc:16:e4,,,,kueche_steckdosen1
IOT,10.40.30.226,shelly: s19,10:20:ba:fb:70:fc,,,,kueche_steckdosen3
IOT,10.40.41.227,switchbot-floorlamp,B8:F8:62:09:F2:04,,,,
IOT,10.40.41.228,Twinkly Weihnachtsgirlande,98:F4:AB:2C:A7:88,,,,"dhcp-host=98:F4:AB:2C:A7:88,10.40.41.223,TwinklyWeihnachtsgirlande"
IOT,10.40.41.229,tuya smartlife TK,,,,,
IOT,10.40.41.230,Withings_waage,00:24:e4:40:2a:72,,,,
IOT,10.40.41.231,Withings_blutdruck,A4:7E:FA:18:2C:64,,,,
IOT,10.40.41.232,pve:homematic-raspi (ccu),BC:24:11:8E:0A:57,330,172.16.0.232,BC:24:11:D3:B3:F4,
IOT,10.40.41.233,shelly: s26,98:a3:16:7a:a3:54,,,,bad_luefter
IOT,10.40.30.233,shelly: s26,98:a3:16:7a:a3:54,,,,bad_luefter
IOT,10.40.41.234,,,,,,
IOT,10.40.41.235,,,,,,
IOT,10.40.41.236,lichterkette,A4:F0:0F:5F:D0:EC,,,,
1 section ip hostname mac pve_ct server_ip server_mac note
199 Sonos 10.40.41.199 SonosWohnzimmer1 48:A6:B8:0B:63:10 48:A6:B8:01:13:EE / dhcp-host=48:A6:B8:0B:63:10,10.40.41.199,SonosWohnzimmer1
200 Sonos 10.40.41.200 SonosWohnzimmer2 48:a6:b8:14:fe:f2 dhcp-host=48:a6:b8:14:fe:f2,10.40.41.200,SonosWohnzimmer2
201 Sonos 10.40.41.201 SonosWohnzimmer3 78:28:ca:d2:0d:ac dhcp-host=78:28:ca:d2:0d:ac,10.40.41.201,SonosWohnzimmer3
202 Sonos IOT 10.40.41.202 10.40.30.202 shelly: s22 a0:85:e3:c8:65:34 kueche_steckdose3-2
203 Sonos IOT 10.40.41.203 10.40.30.203 shelly: s23 ac:15:18:7b:62:20 dieleug-wohnzimmer_licht
204 Sonos IOT 10.40.41.204 10.40.30.204 shelly: s24 14:33:5c:d8:2d:7c bad_spiegelschrank (Shelly Plus RGBW PM
205 Sonos IOT 10.40.41.205 10.40.30.205 shelly: s25 98:a3:16:79:38:50 bad_lichtschalter
206 IOT 10.40.41.206 10.40.30.206 shelly: s20 98:a3:16:7c:5f:ec kueche_steckdosen2
207 IOT 10.40.41.207 10.40.30.207 shelly: s21 20:6e:f1:01:f9:f8 kueche_steckdosen4
208 IOT 10.40.41.208 shelly: s1 E4:B0:63:D7:C7:A4 weihnachten_kueche
209 IOT 10.40.41.209 shelly: s2 28:37:2F:36:F4:7C weihnachten_terrasse
210 IOT 10.40.41.210 shelly: s3 E4:B0:63:E4:EF:7C weihnachten_kinderzimmer
211 IOT 10.40.41.211 10.40.30.211 shelly: s4 E4:B0:63:E4:0F:30 kueche_mikrowelle
212 IOT 10.40.41.212 10.40.30.212 shelly: s5 D8:85:AC:0C:5C:C8 kueche_backofen
213 IOT 10.40.41.213 10.40.30.213 shelly: s6 d0:cf:13:d8:18:fc kueche_kuehlschrank
214 IOT 10.40.41.214 10.40.30.214 shelly: s7 D0:CF:13:D9:94:E8 kueche_tiefkuehlschrank
215 IOT 10.40.41.215 10.40.30.215 shelly: s8 E4:B0:63:FA:FD:E0 kueche_geschirrspueler
216 IOT 10.40.41.216 10.40.30.216 shelly: s9 E8:F6:0A:61:88:08 wohnzimmer_sideboard
217 IOT 10.40.41.217 10.40.30.217 shelly: s10 E4:B0:63:FC:57:E4 bad_sonos
218 IOT 10.40.41.218 10.40.30.218 shelly: s11 E4:B0:63:FA:6C:00 kinderzimmer_sonos
219 IOT 10.40.41.219 shelly: s12 48:f6:ee:c8:e6:ac dieleug_steckdosen
220 IOT 10.40.41.220 10.40.30.220 shelly: s13 48:F6:EE:DD:6A:F4 arbeitszimmer_technik
221 IOT 10.40.41.221 10.40.30.221 shelly: s14 E4:B0:63:FC:59:48 schlafzimmer_bett
222 IOT 10.40.41.222 10.40.30.222 shelly: s15 E4:B0:63:FA:D5:34 technik_pve
223 IOT 10.40.41.223 10.40.30.223 shelly: s16 10:20:ba:fa:f5:40 wohnzimmer_steckdosen
224 IOT 10.40.41.224 10.40.30.224 shelly: s17 10:20:ba:fb:6d:64 bad_steckdosen
225 IOT 10.40.41.225 10.40.30.225 shelly: s18 48:f6:ee:cc:16:e4 kueche_steckdosen1
226 IOT 10.40.41.226 10.40.30.226 shelly: s19 10:20:ba:fb:70:fc kueche_steckdosen3
227 IOT 10.40.41.227 switchbot-floorlamp B8:F8:62:09:F2:04
228 IOT 10.40.41.228 Twinkly Weihnachtsgirlande 98:F4:AB:2C:A7:88 dhcp-host=98:F4:AB:2C:A7:88,10.40.41.223,TwinklyWeihnachtsgirlande
229 IOT 10.40.41.229 tuya smartlife TK
230 IOT 10.40.41.230 Withings_waage 00:24:e4:40:2a:72
231 IOT 10.40.41.231 Withings_blutdruck A4:7E:FA:18:2C:64
232 IOT 10.40.41.232 pve:homematic-raspi (ccu) BC:24:11:8E:0A:57 330 172.16.0.232 BC:24:11:D3:B3:F4
233 IOT 10.40.41.233 10.40.30.233 shelly: s26 98:a3:16:7a:a3:54 bad_luefter
234 IOT 10.40.41.234
235 IOT 10.40.41.235
236 IOT 10.40.41.236 lichterkette A4:F0:0F:5F:D0:EC

262
mcp/shelly/migrate.py Normal file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""TP17 migration runner — anchored VLAN1→VLAN30 cutover for Shellys.
Per-device sequence (Gen2+ RPC), ordered so recovery is ALWAYS possible:
1. probe old IP — must be reachable; record current sta for the log
2. ANCHOR: ap.enable=true — device now also broadcasts its own AP
(Shelly*-<mac>, open) as an emergency anchor.
AP+STA run concurrently, no reboot, sta untouched.
3. set sta -> dirtyvader-iot / VLAN30 static 10.40.30.<octet>
4. Shelly.Reboot
5. poll new IP (10.40.30.<octet>) until it answers, or time out
-> success: AP stays on until end-of-run `deanchor`
-> fail: device is still reachable via its own AP (step 2) — no reset
Only AFTER step 2 do we overwrite sta, so the working fallback that vanishes on
reboot (old sta) is replaced by a fallback that does NOT depend on any WLAN infra
(the device's own AP). That's the whole point of the anchor.
Reuses discover.py (probe/expand/load_inventory) and the same .env as server.py.
Commands:
python3 migrate.py show <ip> read WiFi.GetConfig (read-only)
python3 migrate.py migrate <ip...> [--go] anchor+sta+reboot+verify (dry-run w/o --go)
python3 migrate.py deanchor <ip...> [--go] turn the AP anchor back off (.30.x IPs)
python3 migrate.py auth <ip...> [--go] set uniform admin password (last step)
python3 migrate.py table final tabular overview (old/new/model/fw)
<ip...> accepts single IPs, ranges (10.40.41.205-233) or 'all' (the default sweep).
Batches run sequentially and STOP on the first failed verify (safety).
"""
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import discover # noqa: E402
# ---- shared .env loader (same as server.py) ----
_envfile = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if os.path.exists(_envfile):
for _line in open(_envfile):
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _v = _line.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip().strip('"').strip("'"))
IOT_SSID = os.environ.get("SHELLY_IOT_SSID", "dirtyvader-iot")
IOT_PSK = os.environ.get("SHELLY_IOT_PSK", "")
DEVICE_PW = os.environ.get("SHELLY_DEVICE_PW", "")
V30_NET, V30_MASK, V30_GW = "10.40.30", "255.255.255.0", "10.40.30.1"
LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "migration-log.jsonl")
def _rpc(ip, method, params=None, timeout=8.0):
body = json.dumps({"id": 1, "method": method, "params": params or {}}).encode()
def _mk():
return urllib.request.Request(f"http://{ip}/rpc", data=body,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(_mk(), timeout=timeout) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code == 401 and DEVICE_PW:
mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
mgr.add_password(None, f"http://{ip}/rpc", "admin", DEVICE_PW)
opener = urllib.request.build_opener(urllib.request.HTTPDigestAuthHandler(mgr))
with opener.open(_mk(), timeout=timeout) as r:
return json.load(r)
raise
def _octet(ip):
return ip.rsplit(".", 1)[-1]
def _name_for(mac):
m = discover.load_inventory().get((mac or "").lower())
return m["name"] if m else None
def _log(rec):
with open(LOG, "a") as f:
f.write(json.dumps(rec) + "\n")
def _resolve(specs):
"""Expand CLI ip args into a concrete IP list. 'all' -> default VLAN1 sweep."""
if not specs or specs == ["all"]:
return discover.expand(os.environ.get("SHELLY_SWEEP", "10.40.41.180-250"))
ips = []
for s in specs:
ips.extend(discover.expand(s))
return ips
# ---------------- commands ----------------
def cmd_show(ip):
print(json.dumps(_rpc(ip, "WiFi.GetConfig"), indent=2))
def _anchor(ip):
"""Enable the device's own AP as an emergency anchor. Live, no reboot.
This firmware refuses an OPEN AP (`-103 'pass' should not be empty`), so the
anchor is WPA2 secured with the IOT PSK — a value the operator already has,
and it's removed again by `deanchor`. AP+STA run concurrently; sta untouched.
"""
res = _rpc(ip, "WiFi.SetConfig",
{"config": {"ap": {"enable": True, "is_open": False, "pass": IOT_PSK}}})
if res.get("error"):
return {"enabled": False, "error": res["error"]}
ap = _rpc(ip, "WiFi.GetConfig").get("result", {}).get("ap", {})
return {"enabled": bool(ap.get("enable")), "ap_ssid": ap.get("ssid"),
"secured": not ap.get("is_open")}
def _migrate_one(ip, go, poll=90):
ident = discover.probe(ip)
if not ident:
return {"ip": ip, "ok": False, "error": "not reachable on old IP"}
if not IOT_PSK:
return {"ip": ip, "ok": False, "error": "SHELLY_IOT_PSK not set"}
cur = _rpc(ip, "WiFi.GetConfig").get("result", {}).get("sta", {})
new_ip = f"{V30_NET}.{_octet(ip)}"
name = _name_for(ident["mac"]) or ident.get("name_dev") or "?"
sta = {"ssid": IOT_SSID, "pass": IOT_PSK, "enable": True, "ipv4mode": "static",
"ip": new_ip, "netmask": V30_MASK, "gw": V30_GW, "nameserver": V30_GW}
plan = {"ip": ip, "name": name, "mac": ident["mac"], "model": ident["model"],
"fw": ident["fw"], "old_ssid": cur.get("ssid"), "new_ip": new_ip,
"new_ssid": IOT_SSID}
if not go:
return {**plan, "ok": False, "dry_run": True,
"would": "anchor AP -> set sta(static .30) -> reboot -> verify"}
# 2. anchor
anc = _anchor(ip)
if not anc["enabled"]:
return {**plan, "ok": False, "error": "AP anchor did not enable — aborting before sta write"}
print(f" anchor up: AP '{anc['ap_ssid']}' (WPA2, PSK=IOT-PSK)")
# 3+4. set sta, reboot
_rpc(ip, "WiFi.SetConfig", {"config": {"sta": sta}})
_rpc(ip, "Shelly.Reboot")
print(f" sta set -> {new_ip}, rebooting; polling…")
# 5. poll new IP
t0 = time.time()
while time.time() - t0 < poll:
time.sleep(5)
got = discover.probe(new_ip)
if got and got["mac"].lower() == ident["mac"].lower():
st = _rpc(new_ip, "WiFi.GetStatus").get("result", {})
rec = {**plan, "ok": True, "verified_ssid": st.get("ssid"),
"rssi": st.get("rssi"), "secs": round(time.time() - t0)}
_log(rec)
return rec
return {**plan, "ok": False,
"error": f"did not appear on {new_ip} within {poll}s — "
f"reach it via its AP '{anc['ap_ssid']}' (no reset needed)"}
def cmd_migrate(specs, go):
ips = _resolve(specs)
print(f"{'GO' if go else 'DRY-RUN'} migrate {len(ips)} device(s) "
f"-> {IOT_SSID}/VLAN30 (PSK {'set' if IOT_PSK else 'MISSING'})\n")
ok = 0
for ip in ips:
if not discover.probe(ip):
continue # skip empty slots in a range
print(f"[{ip}] …")
r = _migrate_one(ip, go)
tag = "OK " if r.get("ok") else ("dry" if r.get("dry_run") else "FAIL")
print(f" {tag}: {r.get('name','?')} {ip} -> {r.get('new_ip','?')} "
f"{r.get('error') or r.get('would') or ('rssi '+str(r.get('rssi')))}")
if r.get("ok"):
ok += 1
elif go and not r.get("dry_run"):
print(" >> STOP: batch halted on failure (device recoverable via its AP).")
break
print(f"\n{ok} migrated OK.")
def cmd_deanchor(specs, go):
ips = _resolve(specs) if specs != ["all"] else discover.expand("10.40.30.180-250")
for ip in ips:
if not discover.probe(ip):
continue
if not go:
print(f"dry: would disable AP on {ip}")
continue
_rpc(ip, "WiFi.SetConfig", {"config": {"ap": {"enable": False}}})
cfg = _rpc(ip, "WiFi.GetConfig").get("result", {}).get("ap", {})
print(f"{ip}: AP enable -> {cfg.get('enable')}")
def cmd_auth(specs, go):
if not DEVICE_PW:
print("SHELLY_DEVICE_PW not set — aborting.")
return
for ip in _resolve(specs):
if not discover.probe(ip):
continue
info = _rpc(ip, "Shelly.GetDeviceInfo").get("result", {})
realm = info.get("id")
ha1 = hashlib.sha256(f"admin:{realm}:{DEVICE_PW}".encode()).hexdigest()
if not go:
print(f"dry: {ip} realm={realm} ha1={ha1[:12]}…")
continue
_rpc(ip, "Shelly.SetAuth", {"user": "admin", "realm": realm, "ha1": ha1})
print(f"{ip}: auth set (realm {realm})")
def cmd_table():
inv = discover.load_inventory()
ips = discover.expand("10.40.30.180-250")
from concurrent.futures import ThreadPoolExecutor
rows = []
with ThreadPoolExecutor(max_workers=32) as ex:
for r in ex.map(discover.probe, ips):
if r:
m = inv.get(r["mac"])
rows.append({"id": (m["shelly_id"] if m else "?"),
"name": (m["name"] if m else r.get("name_dev") or "?"),
"old": f"10.40.41.{_octet(r['ip'])}", "new": r["ip"],
"gen": r["gen"], "model": r["model"], "fw": r["fw"]})
rows.sort(key=lambda x: x["id"])
print(f"\n{'id':5} {'name':26} {'old IP':15} {'new IP':15} {'gen':4} {'model':18} fw")
print("-" * 108)
for x in rows:
print(f"{x['id']:5} {x['name']:26} {x['old']:15} {x['new']:15} "
f"g{x['gen']:<3} {x['model']:18} {x['fw']}")
print(f"\n{len(rows)} Shellys on VLAN30.")
def main():
a = sys.argv[1:]
if not a:
print(__doc__)
return
cmd, rest = a[0], a[1:]
go = "--go" in rest
specs = [x for x in rest if not x.startswith("--")]
if cmd == "show":
cmd_show(specs[0])
elif cmd == "migrate":
cmd_migrate(specs, go)
elif cmd == "deanchor":
cmd_deanchor(specs or ["all"], go)
elif cmd == "auth":
cmd_auth(specs, go)
elif cmd == "table":
cmd_table()
else:
print(f"unknown command: {cmd}\n{__doc__}")
if __name__ == "__main__":
main()