|
| 1 | +--- |
| 2 | +phase: quick |
| 3 | +plan: 260409-m53 |
| 4 | +type: execute |
| 5 | +wave: 1 |
| 6 | +depends_on: [] |
| 7 | +files_modified: |
| 8 | + - custom_components/luxtronik2_modbus_proxy/button.py |
| 9 | + - custom_components/luxtronik2_modbus_proxy/sensor.py |
| 10 | + - custom_components/luxtronik2_modbus_proxy/__init__.py |
| 11 | + - custom_components/luxtronik2_modbus_proxy/const.py |
| 12 | + - custom_components/luxtronik2_modbus_proxy/strings.json |
| 13 | + - custom_components/luxtronik2_modbus_proxy/translations/en.json |
| 14 | + - custom_components/luxtronik2_modbus_proxy/translations/de.json |
| 15 | +autonomous: true |
| 16 | +must_haves: |
| 17 | + truths: |
| 18 | + - "A 'Backup Parameters' button entity appears on the device page under Configuration" |
| 19 | + - "Pressing the button reads all parameters and saves a timestamped JSON file" |
| 20 | + - "JSON file is saved to /config/luxtronik2_backups/ with YYYY-MM-DD_HH-MM-SS.json naming" |
| 21 | + - "A persistent_notification fires after backup with summary (count, filename, timestamp)" |
| 22 | + - "A sensor entity shows the last backup timestamp and filename" |
| 23 | + artifacts: |
| 24 | + - path: "custom_components/luxtronik2_modbus_proxy/button.py" |
| 25 | + provides: "Button entity + backup logic" |
| 26 | + - path: "custom_components/luxtronik2_modbus_proxy/sensor.py" |
| 27 | + provides: "Last backup sensor entity (added to existing sensor platform)" |
| 28 | + - path: "custom_components/luxtronik2_modbus_proxy/__init__.py" |
| 29 | + provides: "button platform registration in PLATFORMS" |
| 30 | + key_links: |
| 31 | + - from: "button.py" |
| 32 | + to: "coordinator" |
| 33 | + via: "coordinator._sync_read pattern to get full parameter data with names/types" |
| 34 | + - from: "button.py" |
| 35 | + to: "hass.components.persistent_notification" |
| 36 | + via: "async_create after backup" |
| 37 | + - from: "button.py" |
| 38 | + to: "sensor.py last_backup sensor" |
| 39 | + via: "hass.data storage of last backup metadata" |
| 40 | +--- |
| 41 | + |
| 42 | +<objective> |
| 43 | +Add a "Backup Parameters" button entity that reads all 1,126 parameters from the |
| 44 | +Luxtronik controller and saves them as a timestamped JSON file, plus a sensor showing |
| 45 | +the last backup timestamp. |
| 46 | + |
| 47 | +Purpose: Allow users to create a snapshot of all heat pump parameters before making |
| 48 | +configuration changes, providing a safety net for experimentation. |
| 49 | + |
| 50 | +Output: button.py (new), updated sensor.py, updated __init__.py, updated translations. |
| 51 | +</objective> |
| 52 | + |
| 53 | +<execution_context> |
| 54 | +@$HOME/.claude/get-shit-done/workflows/execute-plan.md |
| 55 | +@$HOME/.claude/get-shit-done/templates/summary.md |
| 56 | +</execution_context> |
| 57 | + |
| 58 | +<context> |
| 59 | +@custom_components/luxtronik2_modbus_proxy/__init__.py |
| 60 | +@custom_components/luxtronik2_modbus_proxy/coordinator.py |
| 61 | +@custom_components/luxtronik2_modbus_proxy/const.py |
| 62 | +@custom_components/luxtronik2_modbus_proxy/sensor.py |
| 63 | +@custom_components/luxtronik2_modbus_proxy/strings.json |
| 64 | +@custom_components/luxtronik2_modbus_proxy/translations/en.json |
| 65 | +@custom_components/luxtronik2_modbus_proxy/translations/de.json |
| 66 | + |
| 67 | +<interfaces> |
| 68 | +<!-- Key types and contracts from existing codebase --> |
| 69 | + |
| 70 | +From coordinator.py: |
| 71 | +```python |
| 72 | +class LuxtronikCoordinator(DataUpdateCoordinator[dict]): |
| 73 | + _host: str |
| 74 | + _port: int |
| 75 | + _lock: asyncio.Lock |
| 76 | + # coordinator.data = {"parameters": dict[int, int], "calculations": dict[int, int]} |
| 77 | +``` |
| 78 | + |
| 79 | +From const.py: |
| 80 | +```python |
| 81 | +DOMAIN = "luxtronik2_modbus_proxy" |
| 82 | +MANUFACTURER = "Alpha Innotec / Novelan" |
| 83 | +MODEL = "Luxtronik 2.0" |
| 84 | +DEFAULT_PORT = 8889 |
| 85 | +``` |
| 86 | + |
| 87 | +From sensor.py (entity pattern): |
| 88 | +```python |
| 89 | +# DeviceInfo imported from homeassistant.helpers.entity (NOT device_registry) |
| 90 | +from homeassistant.helpers.entity import DeviceInfo |
| 91 | +from homeassistant.helpers.update_coordinator import CoordinatorEntity |
| 92 | + |
| 93 | +# Device info pattern used by all entities: |
| 94 | +DeviceInfo( |
| 95 | + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, |
| 96 | + name=MODEL, manufacturer=MANUFACTURER, model=MODEL, |
| 97 | +) |
| 98 | +``` |
| 99 | + |
| 100 | +From luxtronik library (used directly, NOT proxy register_definitions): |
| 101 | +```python |
| 102 | +import luxtronik |
| 103 | +import luxtronik.parameters as _lp |
| 104 | +# _lp.Parameters().parameters -> dict[int, TypedParam] |
| 105 | +# Each TypedParam has: .name (str), .value, .from_heatpump(raw_int), .to_heatpump(value) |
| 106 | +# type(param_obj).__name__ gives the datatype name (e.g., "Celsius", "HeatingMode") |
| 107 | +``` |
| 108 | +</interfaces> |
| 109 | +</context> |
| 110 | + |
| 111 | +<tasks> |
| 112 | + |
| 113 | +<task type="auto"> |
| 114 | + <name>Task 1: Create button.py with backup logic, add constants, register platform</name> |
| 115 | + <files> |
| 116 | + custom_components/luxtronik2_modbus_proxy/button.py, |
| 117 | + custom_components/luxtronik2_modbus_proxy/__init__.py, |
| 118 | + custom_components/luxtronik2_modbus_proxy/const.py |
| 119 | + </files> |
| 120 | + <action> |
| 121 | +**const.py** — Add constant: |
| 122 | +```python |
| 123 | +BACKUP_DIR = "luxtronik2_backups" |
| 124 | +``` |
| 125 | + |
| 126 | +**__init__.py** — Add `"button"` to the PLATFORMS list (line 29): |
| 127 | +```python |
| 128 | +PLATFORMS: list[str] = ["sensor", "select", "number", "button"] |
| 129 | +``` |
| 130 | + |
| 131 | +**button.py** — Create new file implementing the backup button entity. |
| 132 | + |
| 133 | +Structure: |
| 134 | +1. Import `ButtonEntity` from `homeassistant.components.button`. |
| 135 | +2. Import `DeviceInfo` from `homeassistant.helpers.entity` (NOT device_registry). |
| 136 | +3. Import `luxtronik` and `luxtronik.parameters as _lp` for parameter name/type extraction. |
| 137 | +4. Import `json`, `os`, `datetime` from stdlib. |
| 138 | + |
| 139 | +Class `LuxtronikBackupButton(ButtonEntity)`: |
| 140 | +- `_attr_name = "Luxtronik Backup Parameters"` |
| 141 | +- `_attr_icon = "mdi:content-save-all"` |
| 142 | +- `_attr_entity_category = EntityCategory.CONFIG` (appears under Configuration) |
| 143 | +- `_attr_unique_id = f"{entry.entry_id}_backup_parameters"` |
| 144 | +- `device_info` property: same DeviceInfo pattern as sensor.py (identifiers, name, manufacturer, model) |
| 145 | +- Store `coordinator` and `hass` references in `__init__`. |
| 146 | + |
| 147 | +`async_press()` method: |
| 148 | +1. Acquire coordinator._lock (ARCH-03 — serialize with reads/writes). |
| 149 | +2. Inside lock, call `await self.hass.async_add_executor_job(self._sync_backup)`. |
| 150 | +3. After lock release, store backup metadata in `hass.data[DOMAIN][f"{entry_id}_last_backup"]` as dict with `timestamp` (ISO string) and `filename`. |
| 151 | +4. Fire `async_dispatcher_send(hass, f"{DOMAIN}_backup_complete", metadata)` so the sensor updates. |
| 152 | +5. Call `hass.components.persistent_notification.async_create(message, title, notification_id)`: |
| 153 | + - title: "Luxtronik Parameter Backup Complete" |
| 154 | + - message: f"Backed up {count} parameters to `{filename}`\nTimestamp: {timestamp}" |
| 155 | + - notification_id: f"{DOMAIN}_backup" |
| 156 | + |
| 157 | +`_sync_backup()` method (runs in executor, blocking I/O OK): |
| 158 | +1. Create a fresh `luxtronik.Luxtronik` instance using the `__new__` pattern from coordinator._sync_read: |
| 159 | + ```python |
| 160 | + lux = luxtronik.Luxtronik.__new__(luxtronik.Luxtronik) |
| 161 | + lux._host = self._host |
| 162 | + lux._port = self._port |
| 163 | + lux._socket = None |
| 164 | + lux.calculations = luxtronik.Calculations() |
| 165 | + lux.parameters = luxtronik.Parameters() |
| 166 | + lux.visibilities = luxtronik.Visibilities() |
| 167 | + lux.read() |
| 168 | + ``` |
| 169 | +2. Build the JSON structure by iterating `lux.parameters.parameters`: |
| 170 | + ```python |
| 171 | + params_dict = {} |
| 172 | + for idx, param_obj in lux.parameters.parameters.items(): |
| 173 | + if param_obj is None: |
| 174 | + continue |
| 175 | + entry = { |
| 176 | + "name": getattr(param_obj, "name", f"Parameter {idx}"), |
| 177 | + "type": type(param_obj).__name__, |
| 178 | + } |
| 179 | + if hasattr(param_obj, "to_heatpump") and param_obj.value is not None: |
| 180 | + try: |
| 181 | + entry["raw_value"] = int(param_obj.to_heatpump(param_obj.value)) |
| 182 | + except (TypeError, ValueError, AttributeError): |
| 183 | + entry["raw_value"] = None |
| 184 | + else: |
| 185 | + entry["raw_value"] = None |
| 186 | + entry["display_value"] = str(param_obj.value) if param_obj.value is not None else None |
| 187 | + params_dict[str(idx)] = entry |
| 188 | + ``` |
| 189 | +3. Build the full backup dict: |
| 190 | + ```python |
| 191 | + now = datetime.datetime.now(datetime.timezone.utc) |
| 192 | + filename = now.strftime("%Y-%m-%d_%H-%M-%S") + ".json" |
| 193 | + backup = { |
| 194 | + "timestamp": now.isoformat(), |
| 195 | + "host": self._host, |
| 196 | + "parameters": params_dict, |
| 197 | + } |
| 198 | + ``` |
| 199 | +4. Write to `{hass.config.path(BACKUP_DIR)}/{filename}`: |
| 200 | + - Create directory with `os.makedirs(backup_path, exist_ok=True)`. |
| 201 | + - Write JSON with `json.dump(backup, f, indent=2, ensure_ascii=False)`. |
| 202 | +5. Return `(filename, now.isoformat(), len(params_dict))` tuple for the caller. |
| 203 | + |
| 204 | +`async_setup_entry()` function (platform setup): |
| 205 | +- Get coordinator from `hass.data[DOMAIN][entry.entry_id]`. |
| 206 | +- Create one `LuxtronikBackupButton` instance. |
| 207 | +- Call `async_add_entities([button])`. |
| 208 | + |
| 209 | +IMPORTANT: Use the luxtronik library objects directly for reading parameter data (NOT the proxy package register_definitions). The coordinator pattern with `__new__` is proven and documented in coordinator.py. |
| 210 | + </action> |
| 211 | + <verify> |
| 212 | + <automated>cd /home/dwolbeck/claude-code/PUBLIC-luxtronik2-modbus-proxy && python -c " |
| 213 | +import ast, sys |
| 214 | +# Verify button.py parses |
| 215 | +with open('custom_components/luxtronik2_modbus_proxy/button.py') as f: |
| 216 | + tree = ast.parse(f.read()) |
| 217 | +classes = [n.name for n in ast.walk(tree) if isinstance(n, ast.ClassDef)] |
| 218 | +assert 'LuxtronikBackupButton' in classes, f'Missing class, found: {classes}' |
| 219 | +# Verify PLATFORMS includes button |
| 220 | +with open('custom_components/luxtronik2_modbus_proxy/__init__.py') as f: |
| 221 | + src = f.read() |
| 222 | +assert '\"button\"' in src, 'button not in PLATFORMS' |
| 223 | +# Verify BACKUP_DIR in const |
| 224 | +with open('custom_components/luxtronik2_modbus_proxy/const.py') as f: |
| 225 | + src = f.read() |
| 226 | +assert 'BACKUP_DIR' in src, 'BACKUP_DIR not in const.py' |
| 227 | +print('Task 1 verification passed') |
| 228 | +" |
| 229 | + </automated> |
| 230 | + </verify> |
| 231 | + <done> |
| 232 | + - button.py exists with LuxtronikBackupButton class |
| 233 | + - Button has EntityCategory.CONFIG (appears under Configuration) |
| 234 | + - async_press reads all parameters via luxtronik library __new__ pattern |
| 235 | + - JSON saved to config/luxtronik2_backups/YYYY-MM-DD_HH-MM-SS.json |
| 236 | + - persistent_notification fires with parameter count, filename, timestamp |
| 237 | + - Backup metadata stored in hass.data for sensor consumption |
| 238 | + - "button" added to PLATFORMS in __init__.py |
| 239 | + - BACKUP_DIR constant added to const.py |
| 240 | + </done> |
| 241 | +</task> |
| 242 | + |
| 243 | +<task type="auto"> |
| 244 | + <name>Task 2: Add last backup sensor and update all translation files</name> |
| 245 | + <files> |
| 246 | + custom_components/luxtronik2_modbus_proxy/sensor.py, |
| 247 | + custom_components/luxtronik2_modbus_proxy/strings.json, |
| 248 | + custom_components/luxtronik2_modbus_proxy/translations/en.json, |
| 249 | + custom_components/luxtronik2_modbus_proxy/translations/de.json |
| 250 | + </files> |
| 251 | + <action> |
| 252 | +**sensor.py** — Add a "Last Backup" sensor entity. |
| 253 | + |
| 254 | +This sensor does NOT use the coordinator data (it is not a Luxtronik register). Instead, it reads the backup metadata stored by button.py in `hass.data[DOMAIN][f"{entry_id}_last_backup"]`. |
| 255 | + |
| 256 | +Add a new class `LuxtronikLastBackupSensor(SensorEntity)` (NOT CoordinatorEntity — it does not depend on poll data): |
| 257 | +- `_attr_name = "Luxtronik Last Backup"` |
| 258 | +- `_attr_icon = "mdi:backup-restore"` |
| 259 | +- `_attr_entity_category = EntityCategory.DIAGNOSTIC` |
| 260 | +- `_attr_unique_id = f"{entry.entry_id}_last_backup"` |
| 261 | +- `_attr_device_class = SensorDeviceClass.TIMESTAMP` |
| 262 | +- `device_info` property: same DeviceInfo pattern as other entities. |
| 263 | +- Store `hass`, `entry_id` references. |
| 264 | + |
| 265 | +`native_value` property: |
| 266 | +- Read `hass.data[DOMAIN].get(f"{entry_id}_last_backup")`. |
| 267 | +- If None, return None (no backup yet). |
| 268 | +- Parse the ISO timestamp string and return as `datetime.datetime` object (required for TIMESTAMP device class). |
| 269 | + |
| 270 | +`extra_state_attributes` property: |
| 271 | +- Return `{"filename": metadata["filename"]}` if backup metadata exists, else `{}`. |
| 272 | + |
| 273 | +Wire the sensor to update when backup completes. In `async_added_to_hass()`: |
| 274 | +- Subscribe to dispatcher signal `f"{DOMAIN}_backup_complete"` using `async_dispatcher_connect`. |
| 275 | +- On signal, call `self.async_write_ha_state()` to refresh the sensor value. |
| 276 | + |
| 277 | +In `async_will_remove_from_hass()`: |
| 278 | +- The dispatcher unsub is handled automatically if stored via `self.async_on_remove()`. |
| 279 | + |
| 280 | +In `async_setup_entry()`: After the existing entity list creation, append one `LuxtronikLastBackupSensor(hass, entry)` to the entities list. |
| 281 | + |
| 282 | +Add these imports to sensor.py: |
| 283 | +```python |
| 284 | +import datetime |
| 285 | +from homeassistant.helpers.dispatcher import async_dispatcher_connect |
| 286 | +``` |
| 287 | + |
| 288 | +**strings.json** — Add button and sensor entries to the `entity` section: |
| 289 | +```json |
| 290 | +"button": { |
| 291 | + "backup_parameters": { |
| 292 | + "name": "Backup Parameters" |
| 293 | + } |
| 294 | +}, |
| 295 | +"sensor": { |
| 296 | + "last_backup": { |
| 297 | + "name": "Last Backup" |
| 298 | + } |
| 299 | +} |
| 300 | +``` |
| 301 | +Note: The existing `entity` section does not have a `sensor` key yet (core sensors use hardcoded names). Add it alongside the existing `select` and `number` keys. Add `button` as a new key. |
| 302 | + |
| 303 | +**translations/en.json** — Mirror the strings.json changes: add the same `button` and `sensor` keys inside the `entity` section. |
| 304 | + |
| 305 | +**translations/de.json** — Add German translations: |
| 306 | +```json |
| 307 | +"button": { |
| 308 | + "backup_parameters": { |
| 309 | + "name": "Parameter-Sicherung" |
| 310 | + } |
| 311 | +}, |
| 312 | +"sensor": { |
| 313 | + "last_backup": { |
| 314 | + "name": "Letzte Sicherung" |
| 315 | + } |
| 316 | +} |
| 317 | +``` |
| 318 | +Add these inside the existing `entity` section alongside `select` and `number`. |
| 319 | + </action> |
| 320 | + <verify> |
| 321 | + <automated>cd /home/dwolbeck/claude-code/PUBLIC-luxtronik2-modbus-proxy && python -c " |
| 322 | +import ast, json, sys |
| 323 | +# Verify LuxtronikLastBackupSensor in sensor.py |
| 324 | +with open('custom_components/luxtronik2_modbus_proxy/sensor.py') as f: |
| 325 | + tree = ast.parse(f.read()) |
| 326 | +classes = [n.name for n in ast.walk(tree) if isinstance(n, ast.ClassDef)] |
| 327 | +assert 'LuxtronikLastBackupSensor' in classes, f'Missing class, found: {classes}' |
| 328 | +# Verify strings.json has button entity |
| 329 | +with open('custom_components/luxtronik2_modbus_proxy/strings.json') as f: |
| 330 | + strings = json.load(f) |
| 331 | +assert 'button' in strings['entity'], 'button not in strings.json entity section' |
| 332 | +assert 'backup_parameters' in strings['entity']['button'], 'backup_parameters key missing' |
| 333 | +# Verify en.json |
| 334 | +with open('custom_components/luxtronik2_modbus_proxy/translations/en.json') as f: |
| 335 | + en = json.load(f) |
| 336 | +assert 'button' in en['entity'], 'button not in en.json' |
| 337 | +# Verify de.json |
| 338 | +with open('custom_components/luxtronik2_modbus_proxy/translations/de.json') as f: |
| 339 | + de = json.load(f) |
| 340 | +assert 'button' in de['entity'], 'button not in de.json' |
| 341 | +assert de['entity']['button']['backup_parameters']['name'] == 'Parameter-Sicherung' |
| 342 | +print('Task 2 verification passed') |
| 343 | +" |
| 344 | + </automated> |
| 345 | + </verify> |
| 346 | + <done> |
| 347 | + - LuxtronikLastBackupSensor class exists in sensor.py |
| 348 | + - Sensor has TIMESTAMP device class and shows last backup time |
| 349 | + - Sensor extra_state_attributes includes filename |
| 350 | + - Sensor updates via dispatcher signal when backup completes |
| 351 | + - strings.json has button.backup_parameters and sensor.last_backup entries |
| 352 | + - translations/en.json mirrors strings.json |
| 353 | + - translations/de.json has German translations (Parameter-Sicherung, Letzte Sicherung) |
| 354 | + </done> |
| 355 | +</task> |
| 356 | + |
| 357 | +</tasks> |
| 358 | + |
| 359 | +<verification> |
| 360 | +1. `python -c "import ast; ast.parse(open('custom_components/luxtronik2_modbus_proxy/button.py').read())"` — button.py is valid Python |
| 361 | +2. `python -c "import json; json.load(open('custom_components/luxtronik2_modbus_proxy/strings.json'))"` — strings.json is valid JSON |
| 362 | +3. `grep -c '"button"' custom_components/luxtronik2_modbus_proxy/__init__.py` — returns 1 (platform registered) |
| 363 | +4. All three translation files have matching button/sensor entity keys |
| 364 | +</verification> |
| 365 | + |
| 366 | +<success_criteria> |
| 367 | +- A "Backup Parameters" button entity exists under the device's Configuration section |
| 368 | +- Pressing it reads all parameters via luxtronik library and saves JSON to /config/luxtronik2_backups/ |
| 369 | +- JSON format matches spec: {timestamp, host, parameters: {index: {name, type, raw_value, display_value}}} |
| 370 | +- persistent_notification fires with backup summary |
| 371 | +- "Last Backup" sensor shows timestamp and filename attribute |
| 372 | +- All translation files (strings.json, en.json, de.json) updated |
| 373 | +- "button" registered in PLATFORMS list |
| 374 | +</success_criteria> |
| 375 | + |
| 376 | +<output> |
| 377 | +After completion, create `.planning/quick/260409-m53-add-parameter-backup-button-and-backup-v/260409-m53-SUMMARY.md` |
| 378 | +</output> |
0 commit comments