From 1e950af84394efc7eb7391454e858739aa4b8ec9 Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 4 Nov 2025 22:24:25 +0100 Subject: [PATCH 01/10] Improve WiButler 1 lights and button handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect lights based on SWT/BRI_LVL components instead of only DimminActuators - Skip devices with "taster" or "reconnect" in the name when creating lights - Ignore BTNRECON in binary_sensor setup, keep only real button channels (BTN_0/BTN_1/BTN_A0/BTN_B0, …) - Tested with WiButler 1: lights now appear as light.*, buttons as binary_sensor.*, reconnect entities are gone --- custom_components/wibutler/light.py | 42 ++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/custom_components/wibutler/light.py b/custom_components/wibutler/light.py index 419b7c3..ff14654 100644 --- a/custom_components/wibutler/light.py +++ b/custom_components/wibutler/light.py @@ -11,15 +11,42 @@ BRIGHTNESS_SCALE = 255 / 100 # Prozent ↔ HA-Skala MIN_PERCENT = 10 # alles < 10 % = AUS + async def async_setup_entry(hass, entry, async_add_entities): - """Set up Wibutler dimmable lights from a config entry.""" + """Set up Wibutler lights from a config entry.""" hub = hass.data[DOMAIN]["hub"] devices = hub.devices lights = [] + for device_id, device in devices.items(): - """Typo in the API""" - if device.get("type") == "DimminActuators": + dev_type = device.get("type") + name = device.get("name") or "" + name_low = name.lower() + components = device.get("components", []) + comp_names = {c.get("name") for c in components} + + # Debug ins Log, damit man sieht, was ankommt + _LOGGER.debug( + "WiButler-Gerät: id=%s name=%s type=%s components=%s", + device_id, + name, + dev_type, + comp_names, + ) + + # 1) Taster und Reconnect-Geräte NICHT als Light behandeln + if "taster" in name_low or "reconnect" in name_low: + _LOGGER.debug( + "WiButler: Gerät %s als Light übersprungen (Name-Filter).", name + ) + continue + + # 2) bekannte Typen + generische Erkennung: + # alles, was einen SWT- und/oder BRI_LVL-Kanal hat, + # wird als Light behandelt + if dev_type in ("DimminActuators", "DimmingActuators") or \ + "BRI_LVL" in comp_names or "SWT" in comp_names: lights.append(WibutlerLight(hub, device)) async_add_entities(lights, True) @@ -97,8 +124,11 @@ async def async_turn_off(self, **kwargs): response = await self._hub._request("PATCH", url, data) if response: - _LOGGER.info("💡 Light %s ausgeschaltet (letzte Helligkeit %d %%)", - self._attr_name, self._last_brightness_pct) + _LOGGER.info( + "💡 Light %s ausgeschaltet (letzte Helligkeit %d %%)", + self._attr_name, + self._last_brightness_pct, + ) self._is_on = False self._brightness_pct = 0 self.async_write_ha_state() @@ -138,4 +168,4 @@ async def async_added_to_hass(self): def handle_ws_update(self, device_id, components): self._fetch_state(components) - self.async_write_ha_state() \ No newline at end of file + self.async_write_ha_state() From 78a2713ce9c977c3e9d0ad6ce320b4e144813ce3 Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 4 Nov 2025 22:26:39 +0100 Subject: [PATCH 02/10] Ignore BTNRECON in binary sensor setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only register real button channels (BTN_0, BTN_1, BTN_A0, BTN_B0, …) as binary_sensors - Skip BTNRECON completely so reconnect status is not exposed as an entity - Result: push buttons stay as binary_sensor.*, reconnect entities disappear and can be removed in Home Assistant - Tested with WiButler 1: normal button behaviour is unchanged, UI is cleaner --- custom_components/wibutler/binary_sensor.py | 106 +++++++++++++------- 1 file changed, 72 insertions(+), 34 deletions(-) diff --git a/custom_components/wibutler/binary_sensor.py b/custom_components/wibutler/binary_sensor.py index e632df2..1791e1f 100644 --- a/custom_components/wibutler/binary_sensor.py +++ b/custom_components/wibutler/binary_sensor.py @@ -4,6 +4,7 @@ _LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass, entry, async_add_entities): """Set up Wibutler binary sensors from a config entry.""" hub = hass.data[DOMAIN]["hub"] @@ -12,16 +13,31 @@ async def async_setup_entry(hass, entry, async_add_entities): binary_sensors = [] for device_id, device in devices.items(): for component in device.get("components", []): - name = component["name"] + name = component.get("name") + + if not name: + continue # Nur `BTN_*`-Komponenten als Taster registrieren - if name.startswith("BTN"): - binary_sensors.append(WibutlerBinarySensor(hub, device, component)) + if not name.startswith("BTN"): + continue + + # BTNRECON komplett ignorieren (keine Entität anlegen) + if name == "BTNRECON": + _LOGGER.debug( + "WiButler: BTNRECON von Gerät %s (%s) übersprungen.", + device.get("name"), + device_id, + ) + continue + + binary_sensors.append(WibutlerBinarySensor(hub, device, component)) async_add_entities(binary_sensors, True) + BUTTON_MAPPING = { - "SWT": ["BTN_0", "BTN_1"], # Single Rocker Switch + "SWT": ["BTN_0", "BTN_1"], # Single Rocker Switch "SWT_A": ["BTN_A0", "BTN_A1"], # Left side Rocker "SWT_B": ["BTN_B0", "BTN_B1"], # Right side Rocker } @@ -34,51 +50,73 @@ def __init__(self, hub, device, component): """Initialize the binary sensor.""" self._hub = hub self._device = device - self._component = component self._device_id = device["id"] - self._original_name = component["name"] - self._component_names = BUTTON_MAPPING.get(self._original_name, [self._original_name]) + self._component = component + + self._original_name = component["name"] # z.B. BTN_0, BTN_A1, ... self._attr_name = f"{device['name']} - {component['text']}" self._attr_unique_id = f"{device['id']}_{component['name']}" self._attr_is_on = False # Standardmäßig aus - def _fetch_state(self, components): - """Holt den neuen Zustand aus WebSocket-Daten und setzt den Status korrekt.""" - _LOGGER.debug(f"🔄 {self._attr_name} wird aktualisiert... {self._component_names}") - - for component in components: - if component["name"] in BUTTON_MAPPING: - expected_buttons = BUTTON_MAPPING[component["name"]] - - if self._original_name in expected_buttons: - new_value = component["value"] - - # Extrahiere den Nummernteil (0 oder 1) - button_index = new_value[0] # Erstes Zeichen ist die Nummer (0 = oben, 1 = unten) - button_state = new_value[-1] # Letztes Zeichen ist U oder D - - # 🔹 **Sonderfall für einfache Schalter (`SWT`)** - if component["name"] == "SWT": - expected_btn = f"BTN_{button_index}" - else: - expected_btn = f"BTN_A{button_index}" if f"BTN_A{button_index}" in expected_buttons else f"BTN_B{button_index}" - - # Überprüfen, ob die aktuelle Entität die richtige ist - if expected_btn == self._original_name: - self._attr_is_on = button_state == "D" # ON wenn gedrückt (D), OFF wenn losgelassen (U) - - self.async_write_ha_state() # Home Assistant sofort aktualisieren + # Initialen Zustand einmalig aus den Komponenten holen + self._fetch_state(device.get("components", [])) @property def is_on(self) -> bool: """Return true if the button is pressed.""" return self._attr_is_on + @property + def should_poll(self) -> bool: + """Status kommt per WebSocket, kein Polling nötig.""" + return False + + def _fetch_state(self, components): + """Holt den neuen Zustand aus Komponenten und setzt den Status.""" + for component in components: + comp_name = component.get("name") + if comp_name not in BUTTON_MAPPING: + continue + + expected_buttons = BUTTON_MAPPING[comp_name] + new_value = str(component.get("value", "")) + + if not new_value: + continue + + # Erstes Zeichen = Index (0/1), letztes Zeichen = U/D + button_index = new_value[0] # "0" oder "1" + button_state = new_value[-1] # "U" oder "D" + + if comp_name == "SWT": + expected_btn = f"BTN_{button_index}" + else: + # SWT_A / SWT_B + candidate_a = f"BTN_A{button_index}" + candidate_b = f"BTN_B{button_index}" + expected_btn = candidate_a if candidate_a in expected_buttons else candidate_b + + if expected_btn == self._original_name: + new_is_on = button_state == "D" + if new_is_on != self._attr_is_on: + _LOGGER.debug( + "WiButler Button %s (%s/%s) Zustand: %s -> %s", + self._attr_name, + self._device_id, + self._original_name, + self._attr_is_on, + new_is_on, + ) + self._attr_is_on = new_is_on + async def async_added_to_hass(self): """Register for WebSocket updates.""" self._hub.register_listener(self) def handle_ws_update(self, device_id, components): """Process WebSocket update.""" + if device_id != self._device_id: + return + self._fetch_state(components) - self.async_write_ha_state() \ No newline at end of file + self.async_write_ha_state() From de49e3f288d973fd05dd60c876a652d2259579ec Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 19 May 2026 21:53:33 +0000 Subject: [PATCH 03/10] docs: add sub-project documentation CLAUDE.md, MODULES.md, FORK-NOTES.md added for integration with the srv-homelab workspace. Fork-Notes documents the divergence from upstream patrickweh/ha-wibutler (sync point cab4be0, two local commits, uncommitted climate.py work, deferred upstream merge). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++ FORK-NOTES.md | 30 ++++++++++++++++++++++ MODULES.md | 29 +++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 CLAUDE.md create mode 100644 FORK-NOTES.md create mode 100644 MODULES.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4db5d33 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# srv-home-pve-ha-home-wibutler · CLAUDE.md + +@../CLAUDE.md + +## Repo-spezifisch +Sub-Projekt im Workspace `srv-homelab`. Workspace = Concept-Repo (eine Ebene drüber). Vollständige Workspace-Anweisungen in `../CLAUDE.md` (via `@`-Import oben). + +**Dieses Repo ist ein Fork** von [patrickweh/ha-wibutler](https://github.com/patrickweh/ha-wibutler) mit lokalen Anpassungen. Siehe `FORK-NOTES.md` für die Drift zum Upstream. + +## Kontext +Fachliche Ressourcen im Workspace + Parent-Sub-Projekt: +- Aktueller Workspace-Stand: `../STATUS.md` +- Service-Übersicht: `../docs/services.md` +- HA-Sub-Projekt: `../srv-home-pve-ha-home/` +- HA-Inventur: `../srv-home-pve-ha-home/docs/inventur.md` + +## Notion +- Projekt-Workspace: https://www.notion.so/35a7f820f55c815d93b5d6eb881d4ccd (PRJ-10) +- Notion-Sub-Projekt: *(nachzutragen nach REP-Anlage)* +- GitHub (Fork): https://github.com/philw113/ha-wibutler +- GitHub (Upstream): https://github.com/patrickweh/ha-wibutler + +## Was ist drin +Fork der Custom-Integration **Wibutler** für Home Assistant. Wird in der laufenden HA-Instanz unter `/config/custom_components/wibutler/` (auf HAOS: `/mnt/data/supervisor/homeassistant/custom_components/wibutler/`) deployed und ist damit der Code-Stand, der die Wibutler-Hub-Anbindung in HA realisiert. + +## Stack +- Custom HACS-Integration, Python 3.x (in HA-Container 3.14) +- aiohttp REST + WebSocket gegen Wibutler-Hub-API +- Komponenten: binary_sensor, climate, cover, light, sensor, switch (+ rocker im Upstream, lokal noch nicht) + +## Beziehung zum Upstream +- **Letzter Sync-Punkt:** `cab4be0` (2025-08-26, Merge PR #9 von Fochest) +- **Eigene Commits über Sync-Punkt:** `1e950af` (WiButler 1 Lights), `78a2713` (Ignore BTNRECON) +- **Upstream-Stand seit Sync:** v1.2.0 (Refactor `WibutlerEntity` base class, neue `rocker.py`, `strings.json` + translations, api.py-verify_ssl-Fix). Merge bewusst aufgeschoben — siehe `FORK-NOTES.md`. +- **Lokal nicht committed:** signifikante `climate.py`-Erweiterungen (Mode-Komponenten-Logik) — werden mit AUF-* nachgeholt. + +## Deploy +Der Code lebt produktiv in der HAOS-VM 110 unter `/mnt/data/supervisor/homeassistant/custom_components/wibutler/`. Aktuell **kein automatisches Deploy** — nach Commits wird per `scp`/`qm guest exec` von Hand übertragen und HA neugestartet. + +## ⚠️ Read-only-Default (vom Parent-Sub-Repo geerbt) +Default: keine Änderungen am laufenden HA. Code-Änderungen hier im Repo committen erst — Deploy auf VM nur auf explizite Philipp-Freigabe, Plan Mode + Proxmox-Snapshot Pflicht vor Deploy. + +## ⚠️ Keine Secrets +Wibutler-Hub-Zugangsdaten (Username/Passwort/IP) leben in der HA-Config-Entry, nicht im Code. `.env` für lokale Tests ist via `.gitignore` raus. + +## Modul-Architektur +Siehe `MODULES.md`. + +## Test-Strategie +Keine automatisierten Tests. Verifikation auf der laufenden HA-Instanz (Wibutler-Entitäten verfügbar, Statuswechsel kommen an). + +## Auto-Push-Policy +**Auto-Commit:** ja +**Auto-Push:** ja +**Begründung:** Sub-Projekt-Typ `service` — Code-Repo, lokal entwickelt, gepushter Stand wird per Hand auf HA deployed (Deploy ist separater Schritt mit Freigabe). + +Sicherheitsnetz: Pre-Commit-Scan auf Secrets, sensible Datei-Endungen Bestätigung, Force-Push nie automatisch. + +## Auto-Maintenance Routine + +**Bei Beginn der Session:** +- `../STATUS.md` lesen +- `FORK-NOTES.md` lesen (Drift-Stand) +- Aktive Notion-AUFs für dieses Sub-Projekt prüfen + +**Bei signifikanter Änderung:** +1. Committen (Conventional Commits) +2. `FORK-NOTES.md` aktualisieren, falls Drift zum Upstream verändert +3. `../STATUS.md` aktualisieren (kurze Zeile) +4. Auto-Push aktiv — Deploy auf HA bleibt separate Freigabe diff --git a/FORK-NOTES.md b/FORK-NOTES.md new file mode 100644 index 0000000..6ea4489 --- /dev/null +++ b/FORK-NOTES.md @@ -0,0 +1,30 @@ +# Fork-Notes — Drift zum Upstream + +**Letztes Update:** 2026-05-19 + +## Sync-Punkt +Letzter Merge-from-Upstream: `cab4be0` (2025-08-26, Merge PR #9 von Fochest). + +## Eigene Commits über Sync-Punkt (im Fork-`main`) +- `1e950af` (2025-11-04) — Improve WiButler 1 lights and button handling +- `78a2713` (2025-11-04) — Ignore BTNRECON in binary sensor setup + +## Lokal noch nicht committed (Live-VM-Stand vs. Fork-`main`) +- `custom_components/wibutler/climate.py` — **+331 Zeilen lokal**: erweiterte Mode-Komponenten-Logik (`RTSPMODE`/`_prf_*_RTSPMODE`, `CTSP`/`_prf_*_CTSP`, `ETSP`/`_prf_*_ETSP`, Preset Comfort/Eco). Wird mit AUF-* in den Fork gehoben. +- `custom_components/wibutler/binary_sensor.py`, `light.py` — nur Encoding-Drift (Mojibake auf VM), inhaltlich identisch zum Fork. + +## Upstream-Stand vs. Fork +- Upstream `main` ist auf v1.2.0 (Stand 2026-03-07): 7 Commits voraus, 2 zurück, status `diverged`. +- Wesentliche Upstream-Änderungen seit Sync-Punkt: + - `fcb02db` (2026-01-22) — Update api.py: verify_ssl-Fix + Content-Type-Check + - `d189ef4` (2026-03-05) — Refactor: WibutlerEntity base class (16 Files berührt) + - `5d58fb3` (2026-03-07) — Neue `rocker.py` (Rocker-Button-Events + Options-Flow) + - Neue Files: `entity.py`, `rocker.py`, `strings.json`, `translations/de.json`, `translations/en.json` + +## Warum kein Upstream-Merge jetzt +- `climate.py` ist lokal stark überarbeitet (siehe oben) — Upstream-Refactor würde konfliktieren +- Aktueller Bugfix-Bedarf ist orthogonal zum Upstream-Refactor +- Merge wird als eigene AUF eingeplant, nachdem lokale climate.py-Erweiterungen committed sind + +## Bekannte Probleme +- **JSONDecodeError beim Setup** seit HA Core 2026.5.1 (Python 3.14 + aiohttp-Upgrade): `response.json()` in `api.py:_request()` schlägt fehl bei ~161 KB-Devices-Response. Workaround als Patch geplant: `response.json()` → `json.loads(await response.text())`. diff --git a/MODULES.md b/MODULES.md new file mode 100644 index 0000000..52dfce8 --- /dev/null +++ b/MODULES.md @@ -0,0 +1,29 @@ +# Module dieses Repos + +**Letztes Update:** 2026-05-19 durch Claude Code + +## Modul-Übersicht + +### `custom_components/wibutler/` – HA Custom-Integration +**Zweck:** Code der Wibutler-Integration für Home Assistant +**Abhängigkeiten:** Home Assistant Core, aiohttp +**Optional:** nein +**Status:** Aktiv (Fork-Stand 2025-11-04 + lokale climate.py-Erweiterungen) + +Sub-Dateien: +- `__init__.py` – Integration-Bootstrap +- `manifest.json` – HA-Manifest (Domain, Version, Codeowner) +- `config_flow.py` – Setup-UI +- `api.py` – aiohttp REST + WebSocket gegen Wibutler-Hub +- `const.py` – Konstanten +- `binary_sensor.py`, `climate.py`, `cover.py`, `light.py`, `sensor.py`, `switch.py` – Entity-Plattformen + +### `FORK-NOTES.md` – Drift-Doku zum Upstream +**Zweck:** Stand der Abweichungen zum Upstream `patrickweh/ha-wibutler` festhalten — was lokal anders, was bewusst nicht gemergt +**Status:** Aktiv + +## Modul-Erstellung +Bei neuer Funktionalität durch Claude Code zuordnen: +1. Code-Änderung an Integration → in passendes File unter `custom_components/wibutler/` +2. Doku zur Abweichung → in `FORK-NOTES.md` +3. NIEMALS „irgendwo" einbauen ohne Modul-Klarheit From 47e46d5a2dbf35714521e42e0b3ce95db582dcb3 Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 19 May 2026 21:53:46 +0000 Subject: [PATCH 04/10] feat(climate): extended mode/preset handling with CTSP/ETSP fallbacks Local work that has been running on the production HA instance for some time, now committed to the fork. Replaces the minimal climate.py with: - Comfort/Eco preset support via PRESET_COMFORT / PRESET_ECO - CTSP/ETSP setpoint discovery (with _prf_N_ profile variants) - RTSPMODE bonus path tried first; falls back to TSP write when device rejects the mode component (many devices return 422) - Eco fallback: TSP - 2.0 C when no ETSP component is exposed - Comfort fallback: 21.0 C default - FloorHeatingController added to supported device types - Tolerance-based preset state heuristic (+/- 0.5 C) - WebSocket update filtered by device_id (was missing) - Helper functions for raw <-> degC conversion ((raw/2)+10) - Clamp to MIN 5.0 / MAX 30.0 C Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/wibutler/climate.py | 435 ++++++++++++++++++++++---- 1 file changed, 382 insertions(+), 53 deletions(-) diff --git a/custom_components/wibutler/climate.py b/custom_components/wibutler/climate.py index ea6154c..2b6e5bc 100644 --- a/custom_components/wibutler/climate.py +++ b/custom_components/wibutler/climate.py @@ -1,97 +1,426 @@ import logging +from typing import Optional, Tuple, List, Dict, Any + from homeassistant.components.climate import ClimateEntity -from homeassistant.components.climate.const import HVACMode, ClimateEntityFeature +from homeassistant.components.climate.const import ( + HVACMode, + ClimateEntityFeature, + PRESET_COMFORT, + PRESET_ECO, +) from homeassistant.const import UnitOfTemperature from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +# Modus-Komponenten lassen wir als Bonus drin (viele Geräte lehnen sie ab) +MODE_COMPONENT_CANDIDATES: Tuple[str, ...] = ( + "RTSPMODE", + "_prf_0_RTSPMODE", + "_prf_1_RTSPMODE", + "_prf_2_RTSPMODE", + "_prf_3_RTSPMODE", + "MODE", + "OPMODE", + "PROGRAMMODE", +) + +# Komfort-/Eco-Setpoint-Kandidaten +CTSP_CANDIDATES_EXACT: Tuple[str, ...] = ( + "CTSP", + "_prf_0_CTSP", + "_prf_1_CTSP", + "_prf_2_CTSP", + "_prf_3_CTSP", +) +ETSP_CANDIDATES_EXACT: Tuple[str, ...] = ( + "ETSP", + "_prf_0_ETSP", + "_prf_1_ETSP", + "_prf_2_ETSP", + "_prf_3_ETSP", + "ECOTSP", + "_prf_0_ECOTSP", + "_prf_1_ECOTSP", + "_prf_2_ECOTSP", + "_prf_3_ECOTSP", + "ECO_SP", + "_prf_0_ECO_SP", + "_prf_1_ECO_SP", + "_prf_2_ECO_SP", + "_prf_3_ECO_SP", + "ECOSP", + "_prf_0_ECOSP", + "_prf_1_ECOSP", + "_prf_2_ECOSP", + "_prf_3_ECOSP", + "ESP", + "_prf_0_ESP", + "_prf_1_ESP", + "_prf_2_ESP", + "_prf_3_ESP", +) +ETSP_SUBSTRINGS: Tuple[str, ...] = ("ETSP", "ECO", "ESP") + +# Fallback-Strategie, wenn (C/E)TSP nicht verfügbar ist +ECO_FALLBACK_DELTA_C = 2.0 # Eco = TSP - 2.0 °C +COMFORT_FALLBACK_C = 21.0 # Komfort-Standard (°C) +MIN_C = 5.0 +MAX_C = 30.0 + +def _deg_to_raw(deg_c: float) -> int: + # (°C - 10) * 2 + return int(round((float(deg_c) - 10.0) * 2.0)) + +def _raw_to_deg(raw: int) -> float: + # raw/2 + 10 + return (int(raw) / 2.0) + 10.0 + +def _clamp_c(deg_c: float) -> float: + return max(MIN_C, min(MAX_C, deg_c)) + + async def async_setup_entry(hass, entry, async_add_entities): - """Set up Wibutler climate devices from a config entry.""" hub = hass.data[DOMAIN]["hub"] devices = hub.devices - climate_entities = [] - for device_id, device in devices.items(): - if device.get("type") in ["RoomOperatingPanels"]: - climate_entities.append(WibutlerClimate(hub, device)) + entities: List[WibutlerClimate] = [] + for _, device in devices.items(): + if device.get("type") in ["RoomOperatingPanels", "FloorHeatingController"]: + entities.append(WibutlerClimate(hub, device)) + + if not entities: + _LOGGER.info("WiButler Climate: keine passenden Geräte gefunden.") + else: + _LOGGER.debug("WiButler Climate: %d Gerät(e) werden angelegt.", len(entities)) + async_add_entities(entities, True) - async_add_entities(climate_entities, True) class WibutlerClimate(ClimateEntity): - """Representation of a Wibutler Climate Device.""" + """Minimal & robust: Presets über (C/E)TSP, RTSPMODE optional, Toleranzanzeige.""" - def __init__(self, hub, device): - """Initialize the climate device.""" + _attr_hvac_modes = [HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + _attr_preset_modes = [PRESET_COMFORT, PRESET_ECO] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = MIN_C + _attr_max_temp = MAX_C + + def __init__(self, hub, device: Dict[str, Any]): self._hub = hub self._device = device - self._device_id = device['id'] - self._attr_name = device['name'] - self._attr_unique_id = device['id'] - self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - - self._current_temperature = None - self._target_temperature = None + self._device_id: str = device["id"] + self._attr_name = device["name"] + self._attr_unique_id = device["id"] + + # Caches + self._mode_component: Optional[str] = None + self._ctsp_comp: Optional[str] = None + self._etsp_comp: Optional[str] = None + self._raw_tsp: Optional[str] = None + self._raw_ctsp: Optional[str] = None + self._raw_etsp: Optional[str] = None + + self._attr_current_temperature: Optional[float] = None + self._attr_target_temperature: Optional[float] = None + self._attr_preset_mode: Optional[str] = None + self._fetch_state(device.get("components", [])) + # ---- HA properties ---- @property - def current_temperature(self): - return self._current_temperature + def hvac_mode(self) -> HVACMode: + return HVACMode.HEAT @property - def target_temperature(self): - return self._target_temperature + def preset_mode(self) -> Optional[str]: + return self._attr_preset_mode @property - def hvac_mode(self): - """Return the current HVAC mode.""" - return HVACMode.HEAT + def current_temperature(self) -> Optional[float]: + return self._attr_current_temperature @property - def icon(self): - """Setzt ein passenderes Icon für das Gerät.""" - return "mdi:radiator" # Beispiel für Fußbodenheizung oder Heizkörper + def target_temperature(self) -> Optional[float]: + return self._attr_target_temperature + @property + def icon(self) -> str: + return "mdi:radiator" + + # ---- Commands ---- async def async_set_temperature(self, **kwargs): - """Setzt die Zieltemperatur über die API mit korrekter Umrechnung.""" if "temperature" not in kwargs: return + deg_c = _clamp_c(float(kwargs["temperature"])) + raw = _deg_to_raw(deg_c) + payload = {"type": "numeric", "value": str(raw)} + url = f"devices/{self._device_id}/components/TSP" + _LOGGER.debug("📡 [%s] TSP setzen: %.1f°C → raw=%s → %s %s", self._attr_name, deg_c, raw, url, payload) + resp = await self._hub._request("PATCH", url, payload) + if resp: + self._raw_tsp = str(raw) + self._attr_target_temperature = deg_c + self._update_preset_guess() + self.async_write_ha_state() + _LOGGER.info("✅ [%s] Zieltemp gesetzt: %.1f°C (TSP=%s)", self._attr_name, deg_c, raw) + else: + _LOGGER.error("❌ [%s] Zieltemp setzen fehlgeschlagen", self._attr_name) - # Berechne den API-Wert - new_temp = int((kwargs["temperature"] - 10) * 2) + async def async_set_preset_mode(self, preset_mode: str) -> None: + if preset_mode not in (PRESET_COMFORT, PRESET_ECO): + _LOGGER.warning("Unbekanntes Preset: %s", preset_mode) + return - data = { - "type": "numeric", - "value": str(new_temp) - } + # 1) Bonus: RTSPMODE (viele Geräte lehnen mit 422 ab) + comp = self._mode_component or self._detect_mode_component(self._device.get("components", [])) + if comp: + for comp_try in [comp, *MODE_COMPONENT_CANDIDATES]: + for t, v in (("enum", "Komfort" if preset_mode == PRESET_COMFORT else "Sparen"), + ("text", "Komfort" if preset_mode == PRESET_COMFORT else "Sparen"), + ("enum", "1" if preset_mode == PRESET_COMFORT else "2"), + ("numeric", "1" if preset_mode == PRESET_COMFORT else "2")): + payload = {"type": t, "value": v} + url = f"devices/{self._device_id}/components/{comp_try}" + _LOGGER.debug("📡 [%s] Bonus RTSPMODE: %s → %s %s", self._attr_name, comp_try, url, payload) + try: + if await self._hub._request("PATCH", url, payload): + self._attr_preset_mode = preset_mode + self._mode_component = comp_try + self.async_write_ha_state() + _LOGGER.info("✅ [%s] Preset via %s gesetzt (%s=%s)", + self._attr_name, comp_try, t, v) + return + except Exception: + continue + _LOGGER.debug("ℹ️ [%s] RTSPMODE/Profil abgelehnt – setze per Setpoints.", self._attr_name) - _LOGGER.debug(f"📡 PATCH-Request an API: URL=devices/{self._device_id}/components/TSP, Data={data}") + # 2) Primärpfad: TSP ← (C/E)TSP (mit Fallbacks, falls 0/fehlt) + ctsp_name, etsp_name = await self._locate_setpoint_components() - url = f"devices/{self._device_id}/components/TSP" - response = await self._hub._request("PATCH", url, data) + if preset_mode == PRESET_COMFORT: + raw = await self._resolve_comfort_raw(ctsp_name) + src = self._ctsp_comp or "comfort_fallback" + else: + raw = await self._resolve_eco_raw(etsp_name) + src = self._etsp_comp or "eco_fallback" - if response: - _LOGGER.info("🌡️ Temperatur für %s auf %s°C gesetzt (Gesendet: %s)", self._attr_name, kwargs["temperature"], new_temp) - self._target_temperature = kwargs["temperature"] + if raw is None: + _LOGGER.error("❌ [%s] Kein gültiger %s-Setpoint verfügbar → Abbruch", + self._attr_name, "Komfort" if preset_mode == PRESET_COMFORT else "Eco") + return + + payload = {"type": "numeric", "value": str(raw)} + url = f"devices/{self._device_id}/components/TSP" + _LOGGER.debug("📡 [%s] TSP ← %s (%s)", self._attr_name, src, payload) + if await self._hub._request("PATCH", url, payload): + self._attr_preset_mode = preset_mode + self._raw_tsp = str(raw) + self._attr_target_temperature = _raw_to_deg(raw) self.async_write_ha_state() + _LOGGER.info("✅ [%s] Preset %s via TSP←%s gesetzt (raw=%s)", + self._attr_name, preset_mode, src, raw) else: - _LOGGER.error("❌ Fehler beim Setzen der Temperatur für %s", self._attr_name) + _LOGGER.error("❌ [%s] Fallback TSP←%s fehlgeschlagen", self._attr_name, src) + + # ---- State handling ---- + def _fetch_state(self, components: List[Dict[str, Any]]) -> None: + for c in components: + name = c.get("name") + value = c.get("value") + + if name == "RTMP": + try: + self._attr_current_temperature = int(value) / 100 + except Exception: + pass + elif name == "TMP": + try: + self._attr_current_temperature = int(value) / 100 + except Exception: + pass + elif name == "TSP": + try: + self._raw_tsp = str(value) + self._attr_target_temperature = _raw_to_deg(int(value)) + except Exception: + pass + elif name in CTSP_CANDIDATES_EXACT: + self._ctsp_comp = self._ctsp_comp or name + try: + self._raw_ctsp = str(value) + except Exception: + pass + elif name in ETSP_CANDIDATES_EXACT or self._matches_etsp_like(name): + self._etsp_comp = self._etsp_comp or name + try: + self._raw_etsp = str(value) + except Exception: + pass + elif name in MODE_COMPONENT_CANDIDATES: + self._mode_component = self._mode_component or name + # nur Anzeige-Parsing (einige liefern Text, einige 1/2) + try: + s = str(value).strip().lower() + if s.startswith("komf") or s == "1": + self._attr_preset_mode = PRESET_COMFORT + elif s.startswith(("spar", "eco")) or s == "2": + self._attr_preset_mode = PRESET_ECO + except Exception: + pass + + self._update_preset_guess() + + def _update_preset_guess(self) -> None: + # ±0,5°C Toleranz + def near(a: Optional[str], b: Optional[str]) -> bool: + try: + return a is not None and b is not None and abs(int(a) - int(b)) <= 1 + except Exception: + return False + + before = self._attr_preset_mode + if self._raw_tsp is not None: + if near(self._raw_tsp, self._raw_etsp): + self._attr_preset_mode = PRESET_ECO + elif near(self._raw_tsp, self._raw_ctsp): + self._attr_preset_mode = PRESET_COMFORT + + if before != self._attr_preset_mode: + _LOGGER.debug("🔁 [%s] Preset-Heuristik: %s → %s (TSP=%r, CTSP=%r, ETSP=%r)", + self._attr_name, before, self._attr_preset_mode, + self._raw_tsp, self._raw_ctsp, self._raw_etsp) + + def _detect_mode_component(self, components: List[Dict[str, Any]]) -> Optional[str]: + names = [c.get("name") for c in components if c.get("name")] + for cand in MODE_COMPONENT_CANDIDATES: + if cand in names: + return cand + return None + + def _matches_etsp_like(self, name: Optional[str]) -> bool: + if not name: + return False + up = name.upper() + return any(key in up for key in ETSP_SUBSTRINGS) + + # ---- Helpers ---- + async def _locate_setpoint_components(self) -> Tuple[Optional[str], Optional[str]]: + if self._ctsp_comp and self._etsp_comp: + return self._ctsp_comp, self._etsp_comp + + try: + data = await self._hub._request("GET", f"devices/{self._device_id}") + comps = data.get("components", []) if data else [] + except Exception: + comps = [] - def _fetch_state(self, components): - """Holt den neuen Zustand aus WebSocket-Daten und setzt den Status korrekt.""" - for component in components: - if component.get("name") == "TMP": - self._current_temperature = int(component.get("value")) / 100 # TMP / 100 - elif component.get("name") == "TSP": - self._target_temperature = (int(component.get("value")) / 2) + 10 # Umrechnung rückgängig + names = [c.get("name") for c in comps if c.get("name")] + for cand in CTSP_CANDIDATES_EXACT: + if cand in names: + self._ctsp_comp = cand + break + + if not self._etsp_comp: + for cand in ETSP_CANDIDATES_EXACT: + if cand in names: + self._etsp_comp = cand + break + if not self._etsp_comp: + # heuristisch: bestes Match nach Substrings + scored: List[Tuple[int, str]] = [] + for n in names: + up = n.upper() + score = 0 + for i, key in enumerate(ETSP_SUBSTRINGS): + if key in up: + score = max(score, len(ETSP_SUBSTRINGS) - i) + if score: + scored.append((score, n)) + scored.sort(reverse=True) + if scored: + self._etsp_comp = scored[0][1] + + # cached Werte + if self._ctsp_comp: + self._raw_ctsp = self._extract_value(comps, self._ctsp_comp) or self._raw_ctsp + if self._etsp_comp: + self._raw_etsp = self._extract_value(comps, self._etsp_comp) or self._raw_etsp + + _LOGGER.debug("🧭 [%s] Setpoints erkannt: CTSP=%s(raw=%r) ETSP=%s(raw=%r)", + self._attr_name, self._ctsp_comp, self._raw_ctsp, self._etsp_comp, self._raw_etsp) + + return self._ctsp_comp, self._etsp_comp + + async def _resolve_eco_raw(self, etsp_name: Optional[str]) -> Optional[int]: + """Eco-raw ermitteln – aus ETSP wenn vorhanden, sonst TSP-Delta.""" + raw = None + if etsp_name: + v = await self._read_component_value(etsp_name) + try: + if v is not None and int(v) > 0: + raw = int(v) + self._raw_etsp = str(raw) + except Exception: + pass + if raw is None: + # Fallback: Eco = aktuelles TSP - 2.0°C + curr_raw = await self._current_tsp_raw() + if curr_raw is not None: + eco_deg = _clamp_c(_raw_to_deg(curr_raw) - ECO_FALLBACK_DELTA_C) + raw = _deg_to_raw(eco_deg) + return raw + + async def _resolve_comfort_raw(self, ctsp_name: Optional[str]) -> Optional[int]: + """Komfort-raw ermitteln – aus CTSP wenn vorhanden, sonst Standard 21°C.""" + raw = None + if ctsp_name: + v = await self._read_component_value(ctsp_name) + try: + if v is not None and int(v) > 0: + raw = int(v) + self._raw_ctsp = str(raw) + except Exception: + pass + if raw is None: + raw = _deg_to_raw(COMFORT_FALLBACK_C) + return raw + + async def _current_tsp_raw(self) -> Optional[int]: + if self._raw_tsp is not None: + try: + return int(self._raw_tsp) + except Exception: + pass + # Live lesen + v = await self._read_component_value("TSP") + try: + return int(v) if v is not None else None + except Exception: + return None + + def _extract_value(self, comps: List[Dict[str, Any]], name: str) -> Optional[str]: + for c in comps: + if c.get("name") == name: + return c.get("value") + return None + + async def _read_component_value(self, comp_name: str) -> Optional[str]: + try: + data = await self._hub._request("GET", f"devices/{self._device_id}") + comps = data.get("components", []) if data else [] + return self._extract_value(comps, comp_name) + except Exception as e: + _LOGGER.debug("Lesen %s fehlgeschlagen [%s]: %s", comp_name, self._attr_name, e) + return None + + # ---- WS / Updates ---- async def async_added_to_hass(self): - """Register for WebSocket updates.""" self._hub.register_listener(self) def handle_ws_update(self, device_id, components): - """Process WebSocket update.""" + if device_id != self._device_id: + return self._fetch_state(components) - self.async_write_ha_state() \ No newline at end of file + self.async_write_ha_state() From d21626d380662e614fd5ff150069652f24b50f46 Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 19 May 2026 21:53:59 +0000 Subject: [PATCH 05/10] fix(api): work around aiohttp response.json() crash on large responses Since HA Core 2026.5.x (Python 3.14 + newer aiohttp) the WibutlerHub setup fails with JSONDecodeError around position 161320 when fetching /api/devices on hubs with many devices. aiohttp's internal json() decoder breaks the ~160 KB response. response.text() + json.loads reads the whole body first and parses cleanly. Symptom in HA logs: File "custom_components/wibutler/api.py", line 81, in _request return await response.json() json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 161321 Effect of bug: hub.get_devices() returns None -> all Wibutler entities go unavailable on HA start. Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/wibutler/api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/wibutler/api.py b/custom_components/wibutler/api.py index 54fbf84..566839a 100644 --- a/custom_components/wibutler/api.py +++ b/custom_components/wibutler/api.py @@ -78,7 +78,10 @@ async def _request(self, method: str, endpoint: str, data: Optional[Dict[str, An try: async with self.session.request(method, url, headers=headers, json=data) as response: if response.status in (200, 201): - return await response.json() + # Workaround: aiohttp's response.json() bricht in HA 2026.5+ + # (Python 3.14 / neueres aiohttp) bei grossen Wibutler-Responses + # mit JSONDecodeError ab. response.text() + json.loads ist robust. + return json.loads(await response.text()) elif response.status == 401: _LOGGER.warning("Token abgelaufen, erneute Authentifizierung erforderlich.") self.token = None From 8b523a8c01c2664a2ee0ab3537cad46c45100e82 Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 19 May 2026 22:10:29 +0000 Subject: [PATCH 06/10] fix(light): migrate SUPPORT_BRIGHTNESS to ColorMode for HA 2026.5 SUPPORT_BRIGHTNESS was removed from homeassistant.components.light in HA Core 2026.5, causing the wibutler light platform to crash on import with: ImportError: cannot import name 'SUPPORT_BRIGHTNESS' from 'homeassistant.components.light' Replaced with the modern color_mode API: - Class attributes _attr_supported_color_modes = {ColorMode.BRIGHTNESS} and _attr_color_mode = ColorMode.BRIGHTNESS - Removed the obsolete supported_features property Surfaced after the api.py JSONDecodeError fix (d21626d); previously hidden because the JSON crash aborted setup before light.py imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- FORK-NOTES.md | 3 ++- custom_components/wibutler/light.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/FORK-NOTES.md b/FORK-NOTES.md index 6ea4489..7ae3ebd 100644 --- a/FORK-NOTES.md +++ b/FORK-NOTES.md @@ -27,4 +27,5 @@ Letzter Merge-from-Upstream: `cab4be0` (2025-08-26, Merge PR #9 von Fochest). - Merge wird als eigene AUF eingeplant, nachdem lokale climate.py-Erweiterungen committed sind ## Bekannte Probleme -- **JSONDecodeError beim Setup** seit HA Core 2026.5.1 (Python 3.14 + aiohttp-Upgrade): `response.json()` in `api.py:_request()` schlägt fehl bei ~161 KB-Devices-Response. Workaround als Patch geplant: `response.json()` → `json.loads(await response.text())`. +- ~~**JSONDecodeError beim Setup** seit HA Core 2026.5.1~~ — gefixt 2026-05-19 via `api.py`-Patch (`response.json()` → `json.loads(await response.text())`). +- ~~**ImportError `SUPPORT_BRIGHTNESS`** in `light.py` seit HA Core 2026.5~~ — gefixt 2026-05-19: migriert auf `_attr_supported_color_modes = {ColorMode.BRIGHTNESS}` + `_attr_color_mode = ColorMode.BRIGHTNESS`. diff --git a/custom_components/wibutler/light.py b/custom_components/wibutler/light.py index ff14654..41b7a0a 100644 --- a/custom_components/wibutler/light.py +++ b/custom_components/wibutler/light.py @@ -2,7 +2,7 @@ from homeassistant.components.light import ( LightEntity, ATTR_BRIGHTNESS, - SUPPORT_BRIGHTNESS, + ColorMode, ) from .const import DOMAIN @@ -55,6 +55,10 @@ async def async_setup_entry(hass, entry, async_add_entities): class WibutlerLight(LightEntity): """Representation of a Wibutler dimmable light.""" + # HA 2026.5+: SUPPORT_BRIGHTNESS entfernt — Helligkeit ueber color_mode signalisieren + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + def __init__(self, hub, device): self._hub = hub self._device = device @@ -67,10 +71,6 @@ def __init__(self, hub, device): self._fetch_state(device.get("components", [])) # --- Eigenschaften --- - @property - def supported_features(self): - return SUPPORT_BRIGHTNESS - @property def is_on(self): return self._is_on From 293420291c34f08c90e20250ba75269a05fbe2a8 Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 19 May 2026 22:34:11 +0000 Subject: [PATCH 07/10] docs: REP-26 in CLAUDE.md + FORK-NOTES.md eintragen Nachpflege nach Notion-REP-Anlage (Projekte-DB). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 5 ++++- FORK-NOTES.md | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4db5d33..81fdf3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,9 +16,12 @@ Fachliche Ressourcen im Workspace + Parent-Sub-Projekt: ## Notion - Projekt-Workspace: https://www.notion.so/35a7f820f55c815d93b5d6eb881d4ccd (PRJ-10) -- Notion-Sub-Projekt: *(nachzutragen nach REP-Anlage)* +- Notion-Sub-Projekt: [REP-26](https://www.notion.so/3657f820f55c814ba31ace4847bbe88f) (in Projekte-DB) - GitHub (Fork): https://github.com/philw113/ha-wibutler - GitHub (Upstream): https://github.com/patrickweh/ha-wibutler +- Projekt-Workspaces-DB: 761164c1-160c-4861-8985-32e9ffc8a1cf +- Projekte-DB: 19abc847-fe0a-40f2-ac71-b49f63f055ff +- Aufgaben-DB: 61266c90-ed14-4aa7-8f44-df3024884be6 ## Was ist drin Fork der Custom-Integration **Wibutler** für Home Assistant. Wird in der laufenden HA-Instanz unter `/config/custom_components/wibutler/` (auf HAOS: `/mnt/data/supervisor/homeassistant/custom_components/wibutler/`) deployed und ist damit der Code-Stand, der die Wibutler-Hub-Anbindung in HA realisiert. diff --git a/FORK-NOTES.md b/FORK-NOTES.md index 7ae3ebd..161aa38 100644 --- a/FORK-NOTES.md +++ b/FORK-NOTES.md @@ -1,6 +1,7 @@ # Fork-Notes — Drift zum Upstream **Letztes Update:** 2026-05-19 +**Notion-REP:** [REP-26](https://www.notion.so/3657f820f55c814ba31ace4847bbe88f) ## Sync-Punkt Letzter Merge-from-Upstream: `cab4be0` (2025-08-26, Merge PR #9 von Fochest). From 58c11d8143468e1df229a3cc514f92c20cad36b4 Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 19 May 2026 22:38:26 +0000 Subject: [PATCH 08/10] docs(fork-notes): Convention-Abweichung notify-setup dokumentieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROJEKT-PLAYBOOK §12.4 Schritt 4 verlangt notify-on-failure.yml + Health-Check-Stub fuer service-Repos. Hier uebersprungen, da kein CI und der Live-Stand in der HA-VM laeuft, nicht im Repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- FORK-NOTES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/FORK-NOTES.md b/FORK-NOTES.md index 161aa38..35e1187 100644 --- a/FORK-NOTES.md +++ b/FORK-NOTES.md @@ -27,6 +27,15 @@ Letzter Merge-from-Upstream: `cab4be0` (2025-08-26, Merge PR #9 von Fochest). - Aktueller Bugfix-Bedarf ist orthogonal zum Upstream-Refactor - Merge wird als eigene AUF eingeplant, nachdem lokale climate.py-Erweiterungen committed sind +## Convention-Abweichung: kein Notify-Setup +`PROJEKT-PLAYBOOK §12.4 Schritt 4` verlangt für `service`-Repos ein `.github/workflows/notify-on-failure.yml` + Health-Check-Skript-Stub. **Hier bewusst übersprungen**, da: +- Dieses Repo ist ein passiver Fork-Mirror einer HA Custom-Integration — kein CI/CD im Repo +- Der „Live"-Stand läuft in der HA-VM 110 (`/config/custom_components/wibutler/`), nicht im Repo +- Health-Monitoring der Integration findet auf HA-Ebene statt (HA Repairs, Logs, Entity-States) +- Deploy ist manuell (`scp` + base64-Pipe + `ha core restart`), kein automatisierter Pipeline-Lauf den man notifyen müsste + +Falls künftig CI hinzukommt (z.B. Pre-Deploy-Lint-Lauf), nachpflegen. + ## Bekannte Probleme - ~~**JSONDecodeError beim Setup** seit HA Core 2026.5.1~~ — gefixt 2026-05-19 via `api.py`-Patch (`response.json()` → `json.loads(await response.text())`). - ~~**ImportError `SUPPORT_BRIGHTNESS`** in `light.py` seit HA Core 2026.5~~ — gefixt 2026-05-19: migriert auf `_attr_supported_color_modes = {ColorMode.BRIGHTNESS}` + `_attr_color_mode = ColorMode.BRIGHTNESS`. From dc2ad55a75f22efc843b083deebbbf71a429a528 Mon Sep 17 00:00:00 2001 From: philw113 Date: Tue, 19 May 2026 23:30:46 +0000 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20Fork-Pflege-Strategie=20(ADR-001)?= =?UTF-8?q?=20=E2=80=94=20manifest-bump=20+=20upstream-watch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setzt die in ADR-001 dokumentierte Strategie um: - manifest.json: version 1.0.0 -> 1.0.1+philw113-fork-2026-05-19 (PEP-440-Build-Metadata-Suffix, von Semver-Vergleich ignoriert), documentation auf Fork-URL umgehaengt, codeowners um @philw113 erweitert - .github/workflows/upstream-watch.yml: Cron-Action (Mo 09:00 UTC) + workflow_dispatch. Fetcht patrickweh/main, vergleicht mit .github/upstream-last-seen, schickt ntfy bei neuen Commits, commited last-seen-SHA zurueck. - .github/upstream-last-seen: initial cf8916bc (Patrick's "Bump version to 1.2.0" vom 2026-03-07) - docs/decisions/001-fork-pflege-strategie.md: ADR mit Optionen + Entscheidung + Implementierung + Konsequenzen - FORK-NOTES.md: Strategie-Sektion + Sync-Prozess + ueberarbeitete Commit-Liste, Convention-Abweichung-Block angepasst Naechster Schritt (User-Action): HACS-Custom-Repo von patrickweh/ha-wibutler auf philw113/ha-wibutler umstellen. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/upstream-last-seen | 1 + .github/workflows/upstream-watch.yml | 86 +++++++++++++++++++++ FORK-NOTES.md | 45 +++++++---- custom_components/wibutler/manifest.json | 6 +- docs/decisions/001-fork-pflege-strategie.md | 71 +++++++++++++++++ 5 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 .github/upstream-last-seen create mode 100644 .github/workflows/upstream-watch.yml create mode 100644 docs/decisions/001-fork-pflege-strategie.md diff --git a/.github/upstream-last-seen b/.github/upstream-last-seen new file mode 100644 index 0000000..67f0c70 --- /dev/null +++ b/.github/upstream-last-seen @@ -0,0 +1 @@ +cf8916bc90340fc2f70438e899202443f835c707 diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml new file mode 100644 index 0000000..eb68401 --- /dev/null +++ b/.github/workflows/upstream-watch.yml @@ -0,0 +1,86 @@ +name: Upstream Watch + +# Wachhund auf patrickweh/ha-wibutler: meldet via ntfy.sh, sobald Upstream +# neue Commits hat, die in diesem Fork noch nicht gesehen wurden. Letzter +# gesehener Upstream-SHA wird in .github/upstream-last-seen persistiert. +# +# ntfy-Topic: srv-homelab-pve-a5bb56e899af66e2 (pve-Topic, ntfy.sh) +# Strategie-Doku: docs/decisions/001-fork-pflege-strategie.md +# +# Manuell triggerbar via "Run workflow"-Button im Actions-Tab. + +on: + schedule: + - cron: '0 9 * * 1' # Jeden Montag 09:00 UTC (~10/11:00 CET/CEST) + workflow_dispatch: + +permissions: + contents: write + +jobs: + check-upstream: + runs-on: ubuntu-latest + steps: + - name: Checkout fork + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch upstream + id: fetch + run: | + git remote add upstream https://github.com/patrickweh/ha-wibutler.git + git fetch upstream main --quiet + + NEW_SHA=$(git rev-parse upstream/main) + if [ -f .github/upstream-last-seen ]; then + LAST_SEEN=$(cat .github/upstream-last-seen | tr -d '[:space:]') + else + LAST_SEEN=$(git merge-base HEAD upstream/main) + fi + + echo "last_seen=$LAST_SEEN" >> "$GITHUB_OUTPUT" + echo "new_sha=$NEW_SHA" >> "$GITHUB_OUTPUT" + + if [ "$LAST_SEEN" = "$NEW_SHA" ]; then + echo "Upstream unverändert seit $LAST_SEEN — keine Notification." + echo "no_new=true" >> "$GITHUB_OUTPUT" + else + git log --oneline --no-merges "$LAST_SEEN..upstream/main" > /tmp/new_commits.txt + COMMIT_COUNT=$(wc -l < /tmp/new_commits.txt | tr -d '[:space:]') + UPSTREAM_VERSION=$(git show upstream/main:custom_components/wibutler/manifest.json | grep -oE '"version":\s*"[^"]+"' | grep -oE '"[^"]+"$' | tr -d '"' || echo "?") + echo "commit_count=$COMMIT_COUNT" >> "$GITHUB_OUTPUT" + echo "upstream_version=$UPSTREAM_VERSION" >> "$GITHUB_OUTPUT" + echo "no_new=false" >> "$GITHUB_OUTPUT" + echo "Upstream hat $COMMIT_COUNT neue Commits seit $LAST_SEEN:" + cat /tmp/new_commits.txt + fi + + - name: Notify via ntfy + if: steps.fetch.outputs.no_new != 'true' + run: | + BODY="$(cat /tmp/new_commits.txt) + + Upstream-Version: ${{ steps.fetch.outputs.upstream_version }} + Fork: https://github.com/philw113/ha-wibutler + Upstream: https://github.com/patrickweh/ha-wibutler/compare/${{ steps.fetch.outputs.last_seen }}...${{ steps.fetch.outputs.new_sha }} + + Naechster Schritt: git fetch upstream && git merge upstream/main (climate.py-Konflikte erwartet) + Doku: docs/decisions/001-fork-pflege-strategie.md" + + curl -sS -X POST \ + -H "Title: Wibutler Upstream: ${{ steps.fetch.outputs.commit_count }} neue Commit(s)" \ + -H "Priority: default" \ + -H "Tags: zap,wibutler,upstream" \ + -d "$BODY" \ + https://ntfy.sh/srv-homelab-pve-a5bb56e899af66e2 + + - name: Update last-seen marker + if: steps.fetch.outputs.no_new != 'true' + run: | + echo "${{ steps.fetch.outputs.new_sha }}" > .github/upstream-last-seen + git config user.name "upstream-watch-bot" + git config user.email "actions@users.noreply.github.com" + git add .github/upstream-last-seen + git commit -m "chore(upstream-watch): mark ${{ steps.fetch.outputs.new_sha }} as seen [skip ci]" + git push diff --git a/FORK-NOTES.md b/FORK-NOTES.md index 35e1187..d476180 100644 --- a/FORK-NOTES.md +++ b/FORK-NOTES.md @@ -2,6 +2,7 @@ **Letztes Update:** 2026-05-19 **Notion-REP:** [REP-26](https://www.notion.so/3657f820f55c814ba31ace4847bbe88f) +**Pflege-Strategie:** [ADR-001](docs/decisions/001-fork-pflege-strategie.md) ## Sync-Punkt Letzter Merge-from-Upstream: `cab4be0` (2025-08-26, Merge PR #9 von Fochest). @@ -9,32 +10,48 @@ Letzter Merge-from-Upstream: `cab4be0` (2025-08-26, Merge PR #9 von Fochest). ## Eigene Commits über Sync-Punkt (im Fork-`main`) - `1e950af` (2025-11-04) — Improve WiButler 1 lights and button handling - `78a2713` (2025-11-04) — Ignore BTNRECON in binary sensor setup - -## Lokal noch nicht committed (Live-VM-Stand vs. Fork-`main`) -- `custom_components/wibutler/climate.py` — **+331 Zeilen lokal**: erweiterte Mode-Komponenten-Logik (`RTSPMODE`/`_prf_*_RTSPMODE`, `CTSP`/`_prf_*_CTSP`, `ETSP`/`_prf_*_ETSP`, Preset Comfort/Eco). Wird mit AUF-* in den Fork gehoben. -- `custom_components/wibutler/binary_sensor.py`, `light.py` — nur Encoding-Drift (Mojibake auf VM), inhaltlich identisch zum Fork. +- `de49e3f` (2026-05-19) — docs: sub-project documentation +- `47e46d5` (2026-05-19) — feat(climate): extended mode/preset handling with CTSP/ETSP fallbacks +- `d21626d` (2026-05-19) — fix(api): work around aiohttp response.json() crash on large responses +- `8b523a8` (2026-05-19) — fix(light): migrate SUPPORT_BRIGHTNESS to ColorMode for HA 2026.5 ## Upstream-Stand vs. Fork -- Upstream `main` ist auf v1.2.0 (Stand 2026-03-07): 7 Commits voraus, 2 zurück, status `diverged`. +- Upstream `main` ist auf v1.2.0 (Stand 2026-03-07): mehrere Commits voraus, 2 zurück, status `diverged`. - Wesentliche Upstream-Änderungen seit Sync-Punkt: - `fcb02db` (2026-01-22) — Update api.py: verify_ssl-Fix + Content-Type-Check - `d189ef4` (2026-03-05) — Refactor: WibutlerEntity base class (16 Files berührt) - `5d58fb3` (2026-03-07) — Neue `rocker.py` (Rocker-Button-Events + Options-Flow) - Neue Files: `entity.py`, `rocker.py`, `strings.json`, `translations/de.json`, `translations/en.json` +## Pflege-Strategie (siehe [ADR-001](docs/decisions/001-fork-pflege-strategie.md)) +- **HACS-Custom-Repo pollt diesen Fork**, nicht Upstream → kein „Update verfügbar"-Banner-Spam +- **Manifest-`version`-Schema:** `+philw113-fork-` (PEP-440-Build-Metadata, vom Semver-Vergleich ignoriert). Aktuell: `1.0.1+philw113-fork-2026-05-19`. +- **Upstream-Awareness via GitHub-Action** `.github/workflows/upstream-watch.yml` → ntfy-Push an `srv-homelab-pve-...`-Topic (Montags 09:00 UTC, manuell triggerbar im Actions-Tab) +- **Letzter gesehener Upstream-SHA** in `.github/upstream-last-seen` (aktuell: `cf8916bc` = Patrick's „Bump version to 1.2.0" vom 2026-03-07) + +## Sync-Prozess (wenn ntfy meldet) +```bash +cd srv-home-pve-ha-home-wibutler +git fetch upstream main +git merge upstream/main # Konflikte in climate.py erwartet +# Konflikte auflösen (lokale Anpassungen drüber) +# manifest-version bumpen: +philw113-fork- +git commit && git push +# HACS bietet Update im HA-UI an → bestätigen → HACS pullt Fork → HA Restart +``` + ## Warum kein Upstream-Merge jetzt -- `climate.py` ist lokal stark überarbeitet (siehe oben) — Upstream-Refactor würde konfliktieren -- Aktueller Bugfix-Bedarf ist orthogonal zum Upstream-Refactor -- Merge wird als eigene AUF eingeplant, nachdem lokale climate.py-Erweiterungen committed sind +- `climate.py` ist stark divergiert (96 Z. Upstream vs. 426 Z. lokal) — Upstream-Refactor (`WibutlerEntity`-Basisklasse) garantiert konfliktreich +- Aktuell läuft alles stabil seit Bugfix-Welle 2026-05-19, kein akuter Sync-Anlass +- Sync wird ausgelöst, wenn die GitHub-Action eine ntfy-Notification schickt (= Patrick hat einen neuen Commit gepusht) -## Convention-Abweichung: kein Notify-Setup +## Convention-Abweichung: kein notify-on-failure.yml (CI) `PROJEKT-PLAYBOOK §12.4 Schritt 4` verlangt für `service`-Repos ein `.github/workflows/notify-on-failure.yml` + Health-Check-Skript-Stub. **Hier bewusst übersprungen**, da: -- Dieses Repo ist ein passiver Fork-Mirror einer HA Custom-Integration — kein CI/CD im Repo -- Der „Live"-Stand läuft in der HA-VM 110 (`/config/custom_components/wibutler/`), nicht im Repo -- Health-Monitoring der Integration findet auf HA-Ebene statt (HA Repairs, Logs, Entity-States) -- Deploy ist manuell (`scp` + base64-Pipe + `ha core restart`), kein automatisierter Pipeline-Lauf den man notifyen müsste +- Dieses Repo ist ein passiver Fork-Mirror einer HA Custom-Integration — kein klassisches CI/CD +- Der einzige laufende Workflow ist `upstream-watch.yml` (Polling-Bot ohne Build/Test/Deploy), Failures dort sind unkritisch (im Worst Case verpassen wir einen Upstream-Hinweis, der sich beim nächsten Lauf nachholt) +- Der „Live"-Stand läuft in der HA-VM 110 (`/config/custom_components/wibutler/`), nicht im Repo — Health-Monitoring der Integration findet auf HA-Ebene statt (HA Repairs, Logs, Entity-States) -Falls künftig CI hinzukommt (z.B. Pre-Deploy-Lint-Lauf), nachpflegen. +Falls künftig CI (Pre-Deploy-Lint, Tests, etc.) hinzukommt: nachpflegen. ## Bekannte Probleme - ~~**JSONDecodeError beim Setup** seit HA Core 2026.5.1~~ — gefixt 2026-05-19 via `api.py`-Patch (`response.json()` → `json.loads(await response.text())`). diff --git a/custom_components/wibutler/manifest.json b/custom_components/wibutler/manifest.json index fe9c4ad..0d187c4 100644 --- a/custom_components/wibutler/manifest.json +++ b/custom_components/wibutler/manifest.json @@ -1,11 +1,11 @@ { "domain": "wibutler", "name": "wibutler", - "version": "1.0.0", - "documentation": "https://www.home-assistant.io/integrations/powerdog", + "version": "1.0.1+philw113-fork-2026-05-19", + "documentation": "https://github.com/philw113/ha-wibutler", "requirements": [], "dependencies": [], - "codeowners": ["@patrickweh"], + "codeowners": ["@patrickweh", "@philw113"], "config_flow": true, "iot_class": "local_polling", "integrations": ["sensor", "switch", "number", "select", "light"] diff --git a/docs/decisions/001-fork-pflege-strategie.md b/docs/decisions/001-fork-pflege-strategie.md new file mode 100644 index 0000000..4ac5a69 --- /dev/null +++ b/docs/decisions/001-fork-pflege-strategie.md @@ -0,0 +1,71 @@ +# ADR-001 — Fork-Pflege-Strategie ha-wibutler + +**Status:** Akzeptiert +**Datum:** 2026-05-19 +**Kontext:** Sub-Projekt-Anlage (REP-26) + Wibutler-Bugfix-Welle HA 2026.5 + +## Problem + +Wir haben einen Fork `philw113/ha-wibutler` vom Upstream `patrickweh/ha-wibutler` mit lokalen Anpassungen (climate.py-Erweiterung um Comfort/Eco-Presets + CTSP/ETSP-Logik, api.py-JSONDecodeError-Workaround, light.py-ColorMode-Migration). Drei Spannungsfelder: + +1. **HACS-Update-Banner-Schleife:** HACS pollt Upstream-Manifest (1.2.0) vs. unseren Local-Stand (1.0.0) → ewiges „Update verfügbar", obwohl Update unsere Anpassungen zerstören würde. +2. **Upstream-Awareness:** Wenn wir den Custom-Repo komplett aus HACS entfernen, sehen wir auch Patrick's legitime Bugfixes/Features nicht mehr. +3. **Merge-Konflikte:** climate.py ist stark divergiert (96 → 426 Zeilen), Upstream-Refactor (WibutlerEntity base class) konfligiert garantiert. + +## Optionen + +| Option | Pro | Contra | +|---|---|---| +| A) Status Quo (HACS pullt Upstream) | nichts zu tun | Banner-Spam, accidental Update zerschießt Stand | +| B) Fork raus aus HACS, scp-Deploy | simpel | Upstream-Awareness komplett verloren | +| C) Two-Branch-Strategie (`main`=Upstream, `local`=Anpassungen) | saubere Trennung | mentaler Tax: zwei Branches, Deploy aus `local` | +| D) **HACS auf Fork umstellen + manueller Sync + Watch-Bot** | HACS-Pipeline bleibt + Updates kontrolliert | Merge-Konflikte bei jedem Sync (selten — Patrick hat 2026 ~3 Commits gemacht) | + +## Entscheidung + +**Option D.** + +### Implementierung + +1. **HACS-Custom-Repo umstellen** auf `philw113/ha-wibutler` (User-Action in HACS-UI, einmalig) +2. **Manifest-`version`-Schema:** `+philw113-fork-` — z.B. `1.0.1+philw113-fork-2026-05-19`. PEP-440-Build-Metadata-Suffix wird vom Semver-Vergleich ignoriert; HACS sieht keinen Update gegen unseren eigenen Fork. Bei Upstream-Sync: Basis-Version mitziehen, neues Datum. +3. **Upstream-Watch via GitHub-Action** (`.github/workflows/upstream-watch.yml`): + - Cron: Montags 09:00 UTC + - Fetcht `patrickweh/main`, vergleicht mit `.github/upstream-last-seen` + - Bei neuen Commits: ntfy-Push an `srv-homelab-pve-a5bb56e899af66e2`-Topic (Topic aus AUF-98) + commit aktuellen SHA in `.github/upstream-last-seen` zurück ins Repo + - Manuell triggerbar via Actions-Tab „Run workflow" +4. **Sync-Prozess** (manuell, wenn ntfy meldet): + ```bash + cd srv-home-pve-ha-home-wibutler + git fetch upstream main + git merge upstream/main # erwartet Konflikte in climate.py, ggf. light.py + # Konflikte auflösen (lokale Anpassungen wieder drüber) + # manifest-version bumpen: +philw113-fork- + git commit + git push + # HACS bietet Update an → User bestätigt in HA-UI → HACS pullt unseren Fork + # HA Core restart, Verify + ``` + +### Verworfen + +- **A** wegen Banner-Spam und Risiko, dass eine unbeobachtete HACS-Update-Bestätigung unsere climate.py zerschießt. +- **B** weil Upstream-Bugfixes (z.B. der `Update api.py`-Commit für verify_ssl) ohne Awareness liegen bleiben würden. +- **C** weil zwei-Branch-Workflow keinen Mehrwert gegenüber D bietet — Konflikte entstehen so oder so an derselben Stelle (climate.py-Merge), und Deploy-Pipeline über HACS ist mit zwei Branches sperriger. + +## Konsequenzen + +**Positiv:** +- HACS bleibt als Deploy-Mechanismus aktiv — kein `scp` + base64-Pipe-Workaround mehr für jedes Update (außer für Ad-hoc-Patches) +- HACS-„Update verfügbar"-Banner ist still, solange unser Fork-Stand = HA-Stand +- Awareness für Upstream-Bewegung ist via ntfy garantiert, ohne dass Philipp aktiv Upstream-Repo beobachten muss +- Sync-Prozess ist dokumentiert + reproduzierbar + +**Negativ:** +- Bei jedem Upstream-Sync entstehen Merge-Konflikte in `climate.py` (heavily diverged). Bei `binary_sensor.py` und `light.py` evtl. auch, falls Patrick dort wieder anpackt. +- Initial-Setup-Aufwand: User muss einmalig HACS-Custom-Repo umhängen +- Wenn der GitHub-Actions-Runner mal ausfällt, fällt die Awareness lautlos aus → quartalsweise Sanity-Check, ob `.github/upstream-last-seen` aktuell ist + +**Bezogene Aufgaben:** +- Upstream-Merge auf v1.2.0 (Patrick's neuester Stand) — eigene AUF, später +- HACS-Repo-Umstellung — User-Action, dokumentiert in Sub-Projekt-README/CLAUDE.md From 3595b161d509c9f30ecc3ce18c5ac230b136ee35 Mon Sep 17 00:00:00 2001 From: philw113 Date: Wed, 20 May 2026 00:18:14 +0000 Subject: [PATCH 10/10] docs(fork-notes): AUF-208 als Reagier-Karte fuer Watch-Bot verlinken Co-Authored-By: Claude Opus 4.7 (1M context) --- FORK-NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/FORK-NOTES.md b/FORK-NOTES.md index d476180..5c90745 100644 --- a/FORK-NOTES.md +++ b/FORK-NOTES.md @@ -28,6 +28,7 @@ Letzter Merge-from-Upstream: `cab4be0` (2025-08-26, Merge PR #9 von Fochest). - **Manifest-`version`-Schema:** `+philw113-fork-` (PEP-440-Build-Metadata, vom Semver-Vergleich ignoriert). Aktuell: `1.0.1+philw113-fork-2026-05-19`. - **Upstream-Awareness via GitHub-Action** `.github/workflows/upstream-watch.yml` → ntfy-Push an `srv-homelab-pve-...`-Topic (Montags 09:00 UTC, manuell triggerbar im Actions-Tab) - **Letzter gesehener Upstream-SHA** in `.github/upstream-last-seen` (aktuell: `cf8916bc` = Patrick's „Bump version to 1.2.0" vom 2026-03-07) +- **Reagier-Karte in Notion:** [AUF-208](https://www.notion.so/3667f820f55c81ceba2ae2747d3d97b2) (Status: Wartet) — Sync-Prozess, Festlegungen, Diskussions-Log bei ntfy-Hits ## Sync-Prozess (wenn ntfy meldet) ```bash