diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index e8bfea0..7418300 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -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.` 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*-`-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::"), 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.}` + `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. diff --git a/inventory/bf.csv b/inventory/bf.csv index 0416288..1e9f4fa 100644 --- a/inventory/bf.csv +++ b/inventory/bf.csv @@ -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,,,, diff --git a/mcp/shelly/migrate.py b/mcp/shelly/migrate.py new file mode 100644 index 0000000..1ed8ead --- /dev/null +++ b/mcp/shelly/migrate.py @@ -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*-, open) as an emergency anchor. + AP+STA run concurrently, no reboot, sta untouched. + 3. set sta -> dirtyvader-iot / VLAN30 static 10.40.30. + 4. Shelly.Reboot + 5. poll new IP (10.40.30.) 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 read WiFi.GetConfig (read-only) + python3 migrate.py migrate [--go] anchor+sta+reboot+verify (dry-run w/o --go) + python3 migrate.py deanchor [--go] turn the AP anchor back off (.30.x IPs) + python3 migrate.py auth [--go] set uniform admin password (last step) + python3 migrate.py table final tabular overview (old/new/model/fw) + + 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()