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:
@@ -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.
|
- **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`.
|
- 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.
|
- **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).
|
- **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.
|
- â
**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.
|
- **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.
|
- 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`)
|
### TP-R â Router-Capabilities-Sweep ⸠FERTIG (`docs/research/routeros-capabilities.md`)
|
||||||
- **Leitsatz: nativ vor Container, NAS vor Router** (128MB-Flash-Limit).
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -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.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.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.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
|
IOT,10.40.30.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
|
IOT,10.40.30.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)
|
IOT,10.40.30.204,shelly: s24,14:33:5c:d8:2d:7c,,,,bad_spiegelschrank (Shelly Plus RGBW PM
|
||||||
Sonos,10.40.41.205,shelly: s25,98:a3:16:79:38:50,,,,bad_lichtschalter
|
IOT,10.40.30.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.30.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.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.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.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.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.30.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.30.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.30.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.30.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.30.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.30.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.30.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.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.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.30.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.30.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.30.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.30.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.30.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.30.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.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.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.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.229,tuya smartlife TK,,,,,
|
||||||
IOT,10.40.41.230,Withings_waage,00:24:e4:40:2a:72,,,,
|
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.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.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.234,,,,,,
|
||||||
IOT,10.40.41.235,,,,,,
|
IOT,10.40.41.235,,,,,,
|
||||||
IOT,10.40.41.236,lichterkette,A4:F0:0F:5F:D0:EC,,,,
|
IOT,10.40.41.236,lichterkette,A4:F0:0F:5F:D0:EC,,,,
|
||||||
|
|||||||
|
262
mcp/shelly/migrate.py
Normal file
262
mcp/shelly/migrate.py
Normal 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()
|
||||||
Reference in New Issue
Block a user