Initial commit: Hermes Agent Skills collection

This commit is contained in:
Debian
2026-07-12 19:02:59 +00:00
commit e9cc106625
789 changed files with 233126 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
# 1Password Credential Extraction Patterns
## Problem: `--fields credential --format plain` returns empty
When `op item get <ID> --vault Hermes --fields credential --format plain` returns an empty string, the field may be labeled differently internally (e.g. `password` instead of `credential`).
## Fallback: Parse full JSON
```bash
# Save full item JSON
op item get "<ID>" --vault Hermes --format json > /tmp/op_item.json
# Extract the credential field by iterating all fields
HA_TOKEN=$(python3 -c "
import json
with open('/tmp/op_item.json') as f:
item = json.load(f)
for field in item.get('fields', []):
if field.get('label') == 'credential':
print(field['value'])
break
")
echo "Token length: ${#HA_TOKEN}" # Should be > 0
```
## Finding Items by Keyword
```bash
op item list --vault Hermes --format json | python3 -c "
import json, sys
items = json.loads(sys.stdin.read())
for item in items:
title = item.get('title', '')
if any(k in title.lower() for k in ['home', 'assistant', 'token']):
print(f'{item[\"id\"]} → {title}')
"
```
## HA API Token Items in This Homelab
Two items titled "Home Assistant API Token" exist in vault "Hermes":
- `ewviwqhnpcpd2tg2gh5jbv76xm` — older item, has `username` (api_token) + `password` fields
- `mntt343ghnetkyeyeboxxksfde` — newer item, has `credential` + `url` fields
Both contain the same token. The `url` field on the newer item: `https://homeassistant.familie-schoen.com`
## Verified Working Extraction
```bash
# This pattern works reliably:
op item get "mntt343ghnetkyeyeboxxksfde" --vault Hermes --format json > /tmp/ha_item.json
HA_TOKEN=$(python3 -c "
import json
with open('/tmp/ha_item.json') as f:
item = json.load(f)
for f in item.get('fields', []):
if f.get('label') == 'credential':
print(f['value']); break
")
# Verify
curl -sk -H "Authorization: Bearer $HA_TOKEN" "https://homeassistant.familie-schoen.com/api/"
# Expected: {"message":"API running."}
```
## `--value` Flag Does NOT Exist (Service Account Mode)
When using 1Password CLI as a service account (`User Type: SERVICE_ACCOUNT`), the `--value` flag is NOT available:
```bash
# FAILS: unknown flag: --value
op item get <ID> --vault Hermes --fields label=credential --reveal --value
# WORKS: --reveal alone outputs the value (may have surrounding quotes)
op item get <ID> --vault Hermes --fields label=credential --reveal
# Strip quotes in shell:
export HA_TOKEN=$(op item get <ID> --vault Hermes --fields label=credential --reveal 2>/dev/null | tr -d '"')
```
## Quick Inline Pattern (No Temp File)
For scripts that need the token without writing to disk:
```bash
export HA_TOKEN=$(op item get mntt343ghnetkyeyeboxxksfde --vault Hermes --fields label=credential --reveal 2>/dev/null | tr -d '"')
echo "Token length: ${#HA_TOKEN}" # Should be ~183
curl -s -H "Authorization: Bearer $HA_TOKEN" "http://10.0.30.10:8123/api/"
# Expected: {"message":"API running."}
```
@@ -0,0 +1,126 @@
# Bubble Card Pop-up Migration — v3.2.0 Standalone Format
Session: 2026-07-10. Part of the HA dashboard redesign. All 5 pop-ups in the "Haus" dashboard were migrated from legacy format to the new standalone format.
## What Changed
Bubble Card v3.2.0 introduced standalone pop-ups. Legacy pop-ups (content cards as siblings in the view) must be migrated to standalone format (content cards nested inside the pop-up's `cards:` property).
Benefits of standalone format:
- Faster rendering
- More reliable display
- No longer need to live inside a vertical-stack card
- New editor based on HA section editor (drag-and-drop)
- New features: centered mode, classic style, performance mode
## ⚠️ CRITICAL PITFALL: Pop-up Migration Broke Tab Rendering
Moving content cards INTO the pop-up's `cards:` property (as the v3.2.0 migration instructs) caused **all views to render as empty/blank**. The user reported "die tabs sind leer" (the tabs are empty). The pop-up acts as an overlay — its content is hidden until the pop-up is triggered (hash navigation). When using Bubble Card `bottom-navigation` for tab switching, the pop-up is redundant and its content cards become invisible.
### Fix Applied
Removed all `card_type: 'pop-up'` cards from every view entirely. Content cards stay at view level as siblings of the `bottom-navigation` card. This works correctly — tabs show their content, bottom-navigation switches between views.
### Lesson
The v3.2.0 "standalone pop-up" format is designed for pop-ups that open/close on demand (hash-triggered overlays). If you're using `bottom-navigation` for persistent tab navigation (not pop-up overlays), do NOT use pop-up cards at all — just put content cards directly in the view alongside the `bottom-navigation` card. The migration notice is misleading for this use case.
### Final Working Structure
```yaml
views:
- title: Home
path: home
cards:
# NO pop-up card — just content + navigation
- type: custom:mushroom-template-card
primary: "{{ states('weather.forecast_home') }}"
...
- type: custom:power-flow-card-plus
...
- type: custom:bubble-card
card_type: bottom-navigation
routes:
- icon: mdi:home
name: Home
target: "#home"
- icon: mdi:window-shutter
name: Rollos
target: "#rollos"
# ...more routes
```
### Migration Script (WebSocket) — FOR REFERENCE ONLY
This script moves cards into the pop-up. **Do NOT use it if you're using bottom-navigation for tab switching** — it will make your views blank. Only use if you genuinely need pop-up overlays (modal dialogs).
```javascript
(async () => {
const ha = document.querySelector("home-assistant");
const conn = ha.hass.connection;
const cfg = await conn.sendMessagePromise({type: 'lovelace/config', url_path: 'dashboard-haus'});
for (const view of cfg.views) {
const cards = view.cards || [];
const popupIdx = cards.findIndex(c => c.card_type === 'pop-up');
if (popupIdx === -1) continue;
const popup = cards[popupIdx];
const navIdx = cards.findIndex(c => c.card_type === 'bottom-navigation');
// Content cards = everything EXCEPT pop-up and bottom-nav
const contentCards = cards.filter((c, i) => i !== popupIdx && i !== navIdx);
// Move content INTO the pop-up
popup.cards = contentCards;
// Rebuild: [pop-up (with content), bottom-nav]
const newCards = [popup];
if (navIdx !== -1) newCards.push(cards[navIdx]);
view.cards = newCards;
}
await conn.sendMessagePromise({
type: 'lovelace/config/save',
url_path: 'dashboard-haus',
config: cfg
});
})();
```
### Revert Script (Remove Pop-ups Entirely)
This is what actually fixed the blank-tab issue:
```javascript
(async () => {
const ha = document.querySelector("home-assistant");
const conn = ha.hass.connection;
const cfg = await conn.sendMessagePromise({type: 'lovelace/config', url_path: 'dashboard-haus'});
// Remove all pop-up cards from all views
for (const view of cfg.views) {
view.cards = (view.cards || []).filter(c => c.card_type !== 'pop-up');
}
await conn.sendMessagePromise({
type: 'lovelace/config/save',
url_path: 'dashboard-haus',
config: cfg
});
})();
```
### Verification
- Check `cfg.views[].cards` structure — no card should have `card_type: 'pop-up'` with nested `cards:` if using bottom-navigation
- Navigate to each view hash — content should be visible immediately
- 0 render errors in browser console
- **Test on a real browser, not just headless** — the headless browser may fail to render cards after `homeassistant.reload_all` + service worker clear, even when the config is correct
### Headless Browser Rendering Issue
After `homeassistant.reload_all` + clearing service workers/caches, the headless browser failed to render ANY dashboard (including the old Übersicht with 43 cards). `ha-card` count was 0 across all dashboards. The config was verified correct via WebSocket (correct card structure, entity IDs, no errors). This appears to be a headless-browser caching issue, not a config issue. Testing on a real browser/mobile device is the reliable verification method.
@@ -0,0 +1,116 @@
# EVCC Device Inventory — Schön Consulting Homelab
## Network Topology
EVCC runs as HAOS add-on on HA VM 106 (10.0.30.10), port 7070.
Config: `/mnt/data/supervisor/homeassistant/evcc.yaml`
## Configured Devices
### Grid Meter
| Field | Value |
|-------|-------|
| Name | `grid` |
| Type | `custom` (Modbus registers) |
| URI | `10.0.50.201:502` |
| Modbus ID | 3 |
| Power register | 30865 (input, int32nan) |
| Energy register | 30529 (input, uint32nan, scale 0.001) |
### PV Inverters
#### pv1 — SMA Inverter (main roof)
- Template: `sma-inverter-modbus`
- Host: `10.0.50.201:502`
- Typical output: ~700W daytime
#### pv2 — SMA Inverter (secondary)
- Template: `sma-inverter-modbus`
- Host: `10.0.50.203:502`
- Typical output: ~350W daytime
#### hoymiles — Micro-inverter (balcony)
- Template: `hoymiles-opendtu`
- Host: `10.0.40.55` (OpenDTU web interface, HTTP API)
- Typical output: ~670W daytime
### Batteries
#### battery — SMA Sunny Boy Storage
- Template: `sma-sbs-modbus`
- Host: `10.0.50.202:502`
- Max charge power: 4200W
- Watchdog: 60s
- Capacity: ~2 kWh usable (reported)
- Status: Working ✓ (SOC 99%, power 286W as of 2026-06-27)
#### marstek — Marstek Venus E (bridged via HA REST API)
- Type: `custom` (HA REST API bridge — NOT template, NOT Modbus)
- Physical device: 10.0.50.113 (Modbus port 502 closed from EVCC network)
- HA integration: Working ✓ (reads via HA Modbus integration, all entities available)
- Bridged via HA entities:
- power ← `sensor.marstek_venus_modbus_ac_leistung`
- soc ← `sensor.marstek_venus_modbus_batterie_ladezustand`
- energy ← `sensor.marstek_venus_modbus_gesamte_entladeenergie`
- Capacity: 5 kWh, maxchargepower: 2500W, maxdischargepower: 2500W
- Status: Working ✓ via HA bridge (verified 2026-06-27: power=0W, soc=100%, energy=336.34kWh)
- Controllable: No (read-only). For control, add `limitsoc`/`batterymode` setters targeting HA entities.
### Charger
#### wallbox — SMA EV Charger (bridged via HA)
- Type: `custom` (HA REST API bridge)
- Physical device: 10.0.50.205 (CN=SMA3010510346.local)
- HA reaches it via 192.168.100.95 (different VLAN)
- Direct EVCC connection fails: TLS cert without IP-SANs + SMA API returns 400
- Status: Working ✓ via HA bridge
### Tariff
- Provider: Tibber
- Token stored in EVCC config (not in 1Password — rotate if needed)
## Diagnostic Commands
```bash
# Full EVCC state
curl -s http://10.0.30.10:7070/api/state | python3 -m json.tool
# Quick health check — extract key metrics
curl -s http://10.0.30.10:7070/api/state | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f'Grid: {d[\"grid\"][\"power\"]}W')
for i, pv in enumerate(d.get('pv', [])):
print(f'PV[{i}] ({pv[\"name\"]}): {pv[\"power\"]}W')
bat = d.get('battery', {})
print(f'Battery: {bat.get(\"power\")}W, SOC {bat.get(\"soc\")}%')
for dev in bat.get('devices', []):
print(f' {dev[\"name\"]}: {dev.get(\"power\")}W, SOC {dev.get(\"soc\")}, controllable={dev.get(\"controllable\")}')
"
# Check Marstek Modbus connectivity
python3 -c "
import socket
s = socket.socket(); s.settimeout(3)
try: s.connect(('10.0.50.113', 502)); print('OPEN')
except Exception as e: print(f'CLOSED: {e}')
finally: s.close()
"
# List HA Marstek entities
source ~/.hermes/.env
curl -s -H "Authorization: Bearer $HA_TOKEN" http://10.0.30.10:8123/api/states | \
python3 -c "import json,sys; [print(f'{e[\"entity_id\"]}: {e[\"state\"]}') for e in json.load(sys.stdin) if 'marstek' in e['entity_id'].lower()]"
```
## EVCC Config File Locations on HAOS
```bash
# Find all copies
qm guest exec 106 -- find /mnt/data -name "evcc.yaml" -maxdepth 5
# Results:
# /mnt/data/supervisor/tmp/tmpXXXXXX/data/evcc.yaml — temp, ignore
# /mnt/data/supervisor/homeassistant/evcc.yaml — MAIN (referenced by config)
# /mnt/data/supervisor/app_configs/<hash>_evcc/evcc.yaml — addon-managed copy
```
@@ -0,0 +1,239 @@
# Frigate NVR Troubleshooting Reference
## Infrastructure
| Component | Value |
|-----------|-------|
| CT ID | 120 |
| Node | **n5pro (10.0.20.91)** — migrated from proxmox4 |
| IP | 10.0.30.104 |
| Web UI | http://10.0.30.104:5000 |
| API | http://10.0.30.104:5000/api/ |
| go2RTC | http://10.0.30.104:8554/ |
| Config | /config/config.yml (inside CT) |
| Service | frigate.service (systemd) |
| Database | /config/frigate.db (SQLite) |
| GPU | **AMD Radeon (gfx1150)** via /dev/dri/card0 + /dev/dri/renderD128 |
| GPU Driver | Mesa Gallium radeonsi, VAAPI 1.22 (H264/HEVC decode+encode) |
| onboot | yes |
## Version & Features
- Frigate 0.16.4 (also tested 0.17-0)
- Detector: OpenVINO with yolov9s Plus model (320x320), **device: CPU** (OpenVINO is Intel-only, AMD GPU unsupported)
- Tracked objects: person, face, license_plate, dog, cat, car, amazon, ups, package (+ extended postal labels)
- Face recognition: enabled (large model) — **runs on CPU** (OpenVINO GPU fails, auto-fallback to ONNX Runtime CPU)
- License plate recognition: **disabled** (was device: GPU, OpenVINO doesn't support AMD)
- Semantic search: enabled (jinav1 large) — **runs on CPU** (same fallback)
- Hardware accel: **none** (commented out — see GPU Migration section)
## Cameras
### Einfahrt (Driveway)
- IP: 10.0.50.102
- RTSP main: `rtsp://admin:<password>@10.0.50.102:554/h264Preview_01_main`
- RTSP sub: `rtsp://admin:<password>@10.0.50.102:554/h264Preview_01_sub`
- Detection: 640x360 @ 5fps
- Motion threshold: 47, contour area: 19
- LPR mask zones configured
### Terrasse (Patio)
- IP: 10.0.50.103
- Same RTSP pattern as Einfahrt
- Motion threshold: 40, contour area: 27
## MQTT Configuration
```yaml
mqtt:
enabled: true
host: 10.0.30.10
port: 1883
user: frigate
password: <see /config/config.yml or 1Password>
topic_prefix: frigate
client_id: frigate
stats_interval: 60
qos: 0
```
## Verification Commands
```bash
# Check CT status (n5pro, NOT proxmox4)
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.91 "pct status 120"
# Start if stopped
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.91 "pct start 120"
# Check Frigate API
curl -s http://10.0.30.104:5000/api/version
curl -s http://10.0.30.104:5000/api/config | python3 -m json.tool | head -50
# Check service logs
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.91 "pct exec 120 -- cat /dev/shm/logs/frigate/current | tail -30"
# Reload HA Frigate integration (does NOT require full HA restart)
TOKEN=$(cat /tmp/ha_token.txt)
curl -s -X POST "http://10.0.30.10:8123/api/config/config_entries/entry/01KKSHKS4HXBAW1JAKDNHMYHKZ/reload" \
-H "Authorization: Bearer $TOKEN"
```
## GPU Migration: Intel → AMD (2026-07-12)
Frigate CT 120 was migrated from proxmox4 (Intel iGPU) to n5pro (AMD Radeon gfx1150). Three issues arose:
### Issue 1: CT Device Mapping
**Symptom:** `pct start 120` fails with "Device /dev/dri/card1 does not exist"
**Cause:** CT config referenced `/dev/dri/card1` (Intel) and `renderD128` with gid=104 (Intel video group). n5pro has AMD GPU: `/dev/dri/card0` (gid=44=video) and `/dev/dri/renderD128` (gid=993=render).
**Fix:** Update CT config device mappings:
```
dev0: /dev/dri/renderD128,gid=993
dev1: /dev/dri/card0,gid=44
```
Check gids on the target node first: `stat -c '%g %G' /dev/dri/renderD128`
### Issue 2: OpenVINO EP Crash on AMD GPU
**Symptom:** Frigate crashes during startup with:
```
onnxruntime.capi.onnxruntime_pybind11_state.RuntimeException: RUNTIME_EXCEPTION:
Exception during initialization: ... OpenVINO-EP ...
Check '!m_device_map.empty()' failed ...
[GPU] Can't get OPTIMIZATION_CAPABILITIES property as no supported devices found
```
**Cause:** OpenVINO Execution Provider is Intel-only. It calls `ov.compile_model(device_name="GPU")` which fails on AMD. Affects: detectors (if device: GPU), face_recognition embeddings, semantic_search embeddings, LPR.
**Fix:** Set `device: CPU` for detectors and LPR in config.yml. Face recognition and semantic search auto-fallback to CPU — the runner code catches the OpenVINO exception and logs `"OpenVINO failed to build model, using CPU instead"` then uses ONNX Runtime CPUExecutionProvider.
**Can face_recognition/semantic_search run on AMD GPU?** Not with stock Frigate. Would need ONNX Runtime built with ROCm/HIP Execution Provider, which is not shipped with Frigate. The OpenVINO code path is hardcoded for Intel GPU.
### Issue 3: FFmpeg Hardware Acceleration
**Symptom:** `preset-intel-qsv-h264` causes ffmpeg crash. `preset-vaapi-h264` also fails with "Unable to choose an output format".
**Cause:** Intel QSV is Intel-only. `preset-vaapi-h264` is NOT a valid Frigate FFmpeg preset name (Frigate interprets it as an output format, not a hwaccel arg).
**Fix:** Comment out `hwaccel_args` entirely. CPU decode for 2 cameras is sufficient. AMD VAAPI could theoretically work with explicit ffmpeg args (e.g. `-hwaccel vaapi -hwaccel_device /dev/dri/renderD128`) but Frigate's preset system doesn't expose this cleanly.
**Note:** `vainfo` on n5pro confirms AMD VAAPI works for H264/HEVC decode+encode. Installing `mesa-va-drivers` and `vainfo` packages on the Proxmox host enables it.
### Config Changes Applied
```yaml
# BEFORE (Intel)
ffmpeg:
hwaccel_args: preset-intel-qsv-h264
detectors:
ov:
type: openvino
device: GPU
lpr:
enabled: true
device: GPU
# AFTER (AMD)
ffmpeg:
#hwaccel_args: preset-vaapi-h264 # commented out — CPU decode
detectors:
ov:
type: openvino
device: CPU
lpr:
enabled: false # was device: GPU, OpenVINO doesn't support AMD
# face_recognition and semantic_search remain enabled — auto-fallback to CPU
```
## Conflicting HA Addon
**Critical:** There is a SECOND Frigate running as an HA addon (`ccab4aaf_frigate`, repo ccab4aaf). It was in `error` state and conflicts with the CT-based Frigate. Must be stopped:
```bash
# Via HA CLI through qm guest exec
qm guest exec 106 -- sh -c 'ha addons stop ccab4aaf_frigate'
```
Also stop `hm2mqtt` addon (`1376549a_hm2mqtt`) if HAME Energy entities were cleaned up — otherwise it re-discovers them via MQTT:
```bash
qm guest exec 106 -- sh -c 'ha addons stop 1376549a_hm2mqtt'
```
## Common Issues
### CT Stopped (Most Common)
Symptom: All Frigate entities `unavailable` in HA, 10.0.30.104 unreachable.
Fix: `pct start 120` on **n5pro (10.0.20.91)**, wait 60s, reload HA Frigate integration.
### OOM Killer (FIXED 2026-06-27)
Symptom: Frigate process dies repeatedly, log shows "Out of memory: Killed process".
Cause: 4GB RAM was insufficient for 2 cameras + face rec + LPR + embeddings + semantic search.
Fix Applied: CT RAM increased 4GB → 8GB via `pct set 120 -memory 8192` (requires CT stop/start).
Current state: 1.2GB used, 6.9GB free — no OOM risk.
### RTSP 461 Unsupported Transport
Symptom: Log shows `method SETUP failed: 461 Unsupported transport`.
Impact: Transient, doesn't prevent operation. go2rtc falls back to TCP transport.
Action: Ignore — recordings and detection still work.
### Config File Confusion
- `/opt/frigate/config/config.yml` — DEMO config (plays a test video). IGNORE.
- `/config/config.yml` — REAL config with MQTT creds and camera RTSP URLs. THIS is loaded.
- Frigate API `/api/config` shows the effective merged config (file + DB overrides + defaults).
### HA Integration Not Reconnecting
Symptom: Frigate is running, MQTT is publishing, but HA entities stay `unavailable`.
Cause: HA Frigate integration doesn't auto-reconnect when backend comes back.
Fix: Reload via REST API `POST /api/config/config_entries/entry/{ENTRY_ID}/reload`. Full HA restart also works but is slower.
## HA Integration Details
- Config entry ID: `01KKSHKS4HXBAW1JAKDNHMYHKZ`
- Domain: `frigate`
- Title: `10.0.30.104:5000`
- ~96 entities (cameras, switches, sensors, binary_sensors, images)
- Entity naming: `<type>.einfahrt_<feature>_2`, `<type>.terrasse_<feature>_2`
## Frigate Update Path (0.16.4 → 0.17.2)
### Why Not In-Place Update
The CT 120 Frigate was installed via **community-scripts/ProxmoxVE** (`/bin/update` exists). The community script's `update` command redirects to the full CT creation script — it does **not** support in-place updates. It creates a new CT from scratch.
Frigate itself distributes **Docker images only** (no .deb, no pip wheel since 0.8+). The bare-metal source build at `/opt/frigate` cannot be upgraded by simply pulling new source — it requires a full rebuild with correct deps.
### Docker-in-LXC Dead End (Tested 2026-07-12)
Attempting to install Docker inside CT 120 (LXC) to use the official Docker image **fails systematically**:
| Failure | Cause |
|---------|-------|
| `dockerd: iptables not found` | iptables binary missing, symlinks broken |
| `docker.socket: Failed to resolve group docker` | docker group not created during install |
| `failed to create NAT chain DOCKER: iptables not found` | Even after fixing iptables, bridge driver fails |
| dpkg database corruption | Interrupted apt-get leaves `/var/lib/dpkg/updates/` in bad state, `dpkg --configure -a` loops |
| `modprobe overlay` fails (ExecStartPre) | LXC lacks kernel module loading capability |
**Conclusion:** Docker in Proxmox LXC is not viable. Frigate docs explicitly warn: *"If you choose to run Frigate via LXC the setup can be problematic."* Use a **QEMU VM** instead.
### Recommended Migration Path
1. Create a new **QEMU VM** on n5pro (not LXC CT)
2. Install Debian 12 + Docker
3. Use `ghcr.io/blakeblankshear/frigate:stable-rocm` (AMD GPU ROCm build)
4. Copy `/config/config.yml` and `/config/frigate.db` from CT 120
5. Map `/dev/dri/renderD128` for AMD GPU access
6. Update HA Frigate integration URL to new VM IP
7. Stop CT 120 Frigate once VM is verified
### Docker Image Tags Reference
| Tag | Use Case |
|-----|----------|
| `stable` | Standard amd64/arm64 (Intel iGPU, Coral) |
| `stable-rocm` | **AMD GPU** (our n5pro case) |
| `stable-tensorrt` | Nvidia GPU |
| `stable-rk` | Rockchip SBCs |
### Current State (Post-Cleanup 2026-07-12)
- CT 120: Frigate 0.16.4 still active (restored after failed Docker attempt)
- Docker packages purged from CT 120, cores reverted from 8 back to 4
- CT 120 dpkg repaired (`dpkg --configure -a --force-all`)
- HA addon `ccab4aaf_frigate` stopped (conflict)
- HA addon `1376549a_hm2mqtt` stopped (HAME energy bridge)
## Double Take (Defunct)
Double Take (face recognition companion) was completely removed — no service, container, addon, or config remains. 13 orphaned entities linger in HA states but have no entity registry entries. They will disappear on HA restart. Do NOT attempt to restore Double Take unless reinstalling from scratch.
@@ -0,0 +1,207 @@
# Galera Cluster Recovery for Home Assistant Database
## Architecture
```
HA (VM 106, 10.0.30.10)
└─ recorder: mysql://ha_recorder@10.0.30.70:3306/homeassistant
└─ MaxScale VIP (10.0.30.70, keepalived)
├─ maxscale-01 (10.0.30.81, VM 310, n5pro)
└─ maxscale-02 (10.0.30.82, VM 311, proxmox2)
└─ readwritesplit → Galera cluster
├─ db1 (10.0.30.71, VM 300, proxmox2)
├─ db2 (10.0.30.72, VM 301, proxmox4)
└─ db3 (10.0.30.73, VM 302, proxmox5)
```
- **MariaDB**: 11.4.10-MariaDB-deb12
- **MaxScale**: 24.02.9
- **Galera cluster name**: `mariadb-galera`
- **Cluster address**: `gcomm://10.0.30.71,10.0.30.72,10.0.30.73`
- **DB size**: ~18GB (homeassistant schema)
- **SSH to DB nodes**: `ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.{71,72,73}`
- **SSH to MaxScale**: `ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.81`
## Symptoms of Cluster Failure
- HA logs: `MySQLdb.OperationalError: (1045, "Access denied for user 'ha_recorder'@'10.0.30.10'")` or `(2013, "Lost connection to server during query")`
- HA: Recorder service loaded but History/Logbook domains missing
- All 3 MariaDB nodes: `systemctl status mariadb` shows `Active: failed`
- Port 3306 closed on all DB nodes
- MaxScale: all servers show "Down"
## Recovery Procedure
### Step 1: Verify All Nodes Are Down
```bash
for ip in 10.0.30.{71,72,73}; do
echo "=== $ip ==="
ssh -i ~/.ssh/id_ed25519_proxmox debian@$ip "
sudo systemctl is-active mariadb
sudo journalctl -u mariadb --no-pager -n 5
"
done
```
Look for: `Failed to reach primary view`, `gcomm://...: -110 (Connection timed out)`, `Aborting`
### Step 2: Bootstrap From Node 1
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.71 "
sudo systemctl stop mariadb 2>/dev/null
sudo galera_new_cluster
"
```
Wait ~30s, then verify:
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.71 "
sudo mariadb -e \"
SHOW GLOBAL STATUS LIKE 'wsrep_ready';
SHOW GLOBAL STATUS LIKE 'wsrep_cluster_size';
SHOW GLOBAL STATUS LIKE 'wsrep_cluster_status';
SHOW GLOBAL STATUS LIKE 'wsrep_local_state_comment';
\"
"
```
Expected: `wsrep_ready=ON`, `wsrep_cluster_size=1`, `wsrep_cluster_status=Primary`, `wsrep_local_state_comment=Synced`
### Step 3: Enable MaxScale Routing to Donor
During SST, the bootstrapped node becomes Donor/Desynced. MaxScale excludes Donor nodes by default.
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.81 "
maxctrl alter monitor galera-monitor available_when_donor=true
maxctrl list servers
"
```
Verify db1 shows "Master, Synced, Running, Donor/Desynced" and connections work:
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.81 "
mariadb -h 127.0.0.1 -P 3306 -u ha_recorder -p'<password>' --skip-ssl -e \"SELECT 'OK' AS r, @@hostname;\"
"
```
### Step 4: Restart HA (NOW the DB is reachable)
```bash
source ~/.hermes/.env
curl -s -X POST "$HA_URL/api/services/homeassistant/restart" \
-H "Authorization: Bearer $HA_TOKEN" \
-H "Content-Type: application/json" -d '{}'
```
Poll for HA startup (takes 2-3 min with 18GB DB):
```bash
for i in $(seq 1 18); do
sleep 10
STATE=$(curl -s "$HA_URL/api/config" -H "Authorization: Bearer $HA_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state','?'))" 2>/dev/null)
echo "Check $i: $STATE"
[ "$STATE" = "RUNNING" ] && break
done
```
Verify recorder/history/logbook:
```bash
curl -s "$HA_URL/api/services" -H "Authorization: Bearer $HA_TOKEN" | python3 -c "
import sys,json
data=json.load(sys.stdin)
services=set(s['domain'] for s in data)
for d in ['recorder','history','logbook']:
print(f'{d}: {\"✓\" if d in services else \"✗\"}')
"
```
Test history API:
```bash
curl -s -w " HTTP:%{http_code}" "$HA_URL/api/history/period?filter_entity_id=sun.sun" \
-H "Authorization: Bearer $HA_TOKEN" | head -1 | cut -c1-100
```
### Step 5: Join db2 (SEQUENTIAL — one at a time!)
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.72 "sudo systemctl start --no-block mariadb"
```
Monitor SST progress (18GB DB takes ~30 min):
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.72 "
for i in \$(seq 1 120); do
STATUS=\$(sudo systemctl is-active mariadb 2>/dev/null)
if [ \"\$STATUS\" = 'active' ]; then
WSREP=\$(sudo mariadb -e \"SHOW GLOBAL STATUS LIKE 'wsrep_local_state_comment'\" 2>/dev/null | awk '/wsrep_local_state_comment/{print \$2}')
echo \"Poll \$i: ACTIVE, state=\$WSREP\"
[ \"\$WSREP\" = 'Synced' ] && echo 'DB2 SYNCED!' && break
else
SIZE=\$(sudo du -sh /var/lib/mysql/ 2>/dev/null | awk '{print \$1}')
echo \"Poll \$i: \$STATUS, datadir=\$SIZE\"
fi
sleep 10
done
"
```
Also check from donor side:
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.71 "
sudo mariadb -e \"SHOW GLOBAL STATUS LIKE 'wsrep_local_state_comment';\"
ps aux | grep mariabackup | grep -v grep
"
```
### Step 6: Join db3 (ONLY after db2 is Synced)
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.73 "sudo systemctl start --no-block mariadb"
```
Same monitoring loop as Step 5.
### Step 7: Restore MaxScale Settings
Once all 3 nodes are Synced:
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.81 "
maxctrl alter monitor galera-monitor available_when_donor=false
maxctrl clear server db2 maintenance
maxctrl clear server db3 maintenance
maxctrl list servers
"
```
All 3 should show "Synced, Running".
## Key Pitfalls
1. **Parallel SST deadlock**: Starting 2+ joiners simultaneously → "No donor candidates temporarily available in suitable state". Both stall forever. FIX: start one, wait for Synced, then start next.
2. **MaxScale `available_when_donor`**: Default `false` means MaxScale won't route ANY traffic while the only available node is a donor. HA Recorder gets "Lost connection" / "Access denied". FIX: `maxctrl alter monitor galera-monitor available_when_donor=true` before joining additional nodes.
3. **HA Recorder retry exhaustion**: `db_max_retries: 20` in configuration.yaml. If DB unreachable at boot, Recorder gives up after ~20 retries × 5s = 100s. History/Logbook never load. FIX: ensure DB is reachable via MaxScale BEFORE restarting HA.
4. **`maxctrl set server db1 master` fails on monitored servers**: "monitored server, only maintenance/drain can be altered". Don't try to manually set master role — the galera-monitor does this automatically based on wsrep state.
5. **HA startup time with large DB**: With 18GB DB, HA takes 2-3 minutes to start (schema migration/validation). Don't assume failure if `api/config` returns `NOT_RUNNING` for the first few minutes.
6. **grastate.dat may not exist**: On clean shutdown or fresh install, `grastate.ini`/`grastate.dat` may not be present. This doesn't prevent bootstrap — just run `galera_new_cluster` directly.
## Credentials
All DB credentials should be stored in 1Password (Vault "Hermes"):
- `ha_recorder` MySQL password (used in HA configuration.yaml db_url)
- MaxScale admin user/password
- Mariabackup SST auth credentials
Currently in HA configuration.yaml:
```yaml
recorder:
db_url: mysql://ha_recorder:***@10.0.30.70:3306/homeassistant?charset=utf8mb4
purge_keep_days: 30
db_max_retries: 20
```
@@ -0,0 +1,135 @@
# HA Browser Console Access (No Local Token Needed)
When no HA API token is available locally (`.env` missing, 1Password inaccessible, token redacted after compaction), the HA web UI's browser session can be used to query the REST API directly. This is a reliable fallback that avoids needing to create/manage long-lived tokens.
## Technique
1. **Navigate to HA web UI** via `browser_navigate` to `http://10.0.30.10:8123` (internal) or `https://homeassistant.familie-schoen.com` (external).
2. **Log in** with username/password via the login form (credentials in memory: dominik / 28acaneltO!).
3. **Extract the bearer token** from the browser console:
```javascript
(async () => {
const conn = await window.hassConnection; // It's a Promise!
const token = conn.auth.data.access_token;
return 'Token len: ' + token.length + ' | prefix: ' + token.substring(0, 20);
})()
```
Key details:
- `window.hassConnection` is a **Promise** (not a plain object). Must `await` it.
- Resolves to `{ auth, conn }`.
- Token is at `conn.auth.data.access_token` (NOT `accessToken` — camelCase key is `access_token`).
- Token is a JWT (~183 chars, starts with `eyJhbG...`).
- Token expires (refresh handled by HA frontend automatically). If a query fails with 401, re-extract the token.
4. **Query REST API via `fetch()`** in `browser_console`:
```javascript
(async () => {
const conn = await window.hassConnection;
const token = conn.auth.data.access_token;
// Get all states
const resp = await fetch('/api/states', {
headers: { 'Authorization': 'Bearer ' + token }
});
const states = await resp.json();
// Filter for relevant entities
const powerStates = states.filter(s => {
const eid = s.entity_id.toLowerCase();
return eid.includes('power') || eid.includes('netz') || /* ... */;
}).map(s => ({ eid: s.entity_id, state: s.state, unit: s.attributes.unit_of_measurement }));
return JSON.stringify(powerStates);
})()
```
5. **Query history** with time ranges:
```javascript
(async () => {
const conn = await window.hassConnection;
const token = conn.auth.data.access_token;
const entities = 'sensor.evcc_grid_power,sensor.evcc_home_power';
const url = '/api/history/period/2026-07-09T03:00:00?filter_entity_id=' + entities +
'&end_time=2026-07-09T06:00:00&minimal_response';
const resp = await fetch(url, {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await resp.json();
// data is an array of arrays — one per entity, each containing state-change objects
// Each state object: { state, last_changed, attributes }
// Bucket by time interval for summarization
const buckets = {};
for (const h of data[0]) {
const t = h.last_changed.substring(11, 16); // HH:MM
const bucket = t.substring(0, 3) + (parseInt(t.substring(3,5)) >= 30 ? '30' : '00');
const v = parseFloat(h.state);
if (isNaN(v)) continue;
if (!buckets[bucket]) buckets[bucket] = [];
buckets[bucket].push(v);
}
return JSON.stringify(
Object.entries(buckets).map(([time, vals]) => ({
time,
avg: (vals.reduce((a,b)=>a+b,0)/vals.length).toFixed(0),
max: Math.max(...vals).toFixed(0)
})).sort((a,b) => a.time.localeCompare(b.time))
);
})()
```
## Advantages Over Other Methods
| Method | Requires | Limitations |
|--------|----------|-------------|
| REST API (curl/terminal) | Local token in `.env` or 1Password | Token may be missing/redacted |
| WebSocket (Python) | Local token + Python websocket lib | Heavy setup, token redaction in terminal |
| Proxmox guest exec | SSH to proxmox1 | Guest agent often down |
| **Browser console** | **Browser + HA login** | **Always works if web UI is reachable** |
## Alternative Token Extraction (When hassConnection Goes Stale)
After re-login or long browser sessions, `window.hassConnection` may resolve to an empty object or `conn.auth` becomes `undefined`. Use this fallback instead — it accesses the `hass` object directly from the DOM element (no Promise to await):
```javascript
(async () => {
const ha = document.querySelector("home-assistant");
const token = ha.hass.auth.accessToken; // Direct property, NOT a Promise
const resp = await fetch('/api/states', {
headers: { 'Authorization': 'Bearer ' + token }
});
return JSON.stringify(await resp.json());
})()
```
Key differences from the `hassConnection` approach:
- `hass.auth.accessToken` — direct property on the `home-assistant` custom element's `hass` object. No await needed.
- `hass.auth` has keys: `["data", "_saveTokens"]`. The `accessToken` getter returns the JWT string.
- If `document.querySelector("home-assistant")?.hass` is undefined, the page hasn't fully loaded. Wait and retry, or re-navigate.
- The `hass.connection` object (WebSocket connection) is at `hass.hass.connection` — but its `auth` property is NOT populated. Use `hass.hass.auth` instead.
**When to use which:**
| Scenario | Method |
|----------|--------|
| Fresh page load, quick query | `(await window.hassConnection).auth.data.access_token` |
| After re-login, stale session | `document.querySelector("home-assistant").hass.auth.accessToken` |
| `hassConnection` returns empty `{}` | Same fallback — DOM element approach |
## Pitfalls
- **`window.hassConnection` is a Promise, not an object.** Calling `Object.keys(window.hassConnection)` returns empty because it's a Promise. Must `await` it first.
- **Token key is `access_token` (snake_case), not `accessToken` (camelCase).** The `auth.data` object uses snake_case: `access_token`, `refresh_token`, `token_type`, `expires_in`.
- **Multi-line JSON in `browser_console` expression.** The `browser_console` tool evaluates a single JavaScript expression. Multi-statement code must be wrapped in an IIFE: `(async () => { ... })()`. Pretty-printing with `JSON.stringify(obj, null, 2)` can cause parse errors in some cases — use `JSON.stringify(obj)` (no spacing) for reliability.
- **`browser_console` returns truncated results for large outputs.** If the result exceeds ~5000 chars, it may be truncated. Keep queries focused — filter entities before returning, or paginate.
- **History API returns array-of-arrays.** `/api/history/period/{timestamp}?filter_entity_id=A,B,C` returns `[ [states_for_A], [states_for_B], [states_for_C] ]`. Each inner array contains state-change records with `{ state, last_changed, last_updated, attributes }`.
- **Use `&minimal_response` for history queries.** Without it, each state record includes full attributes, making responses enormous. With it, attributes are stripped — sufficient for numerical analysis.
- **`window.hassConnection` goes stale during long browser sessions.** After ~10-15 minutes of inactivity (or after the browser tab loses focus), the HA frontend's WebSocket connection drops and `window.hassConnection` may resolve to an empty object `{}` (keys: none) or `conn.auth` becomes `undefined`. Symptom: `TypeError: Cannot read properties of undefined (reading 'auth')`. **Fix**: re-navigate to the HA URL (`browser_navigate` to `http://10.0.30.10:8123`), wait 5s for the page to load, re-login if the login form appears, then re-extract the token. After re-login, verify the page has fully loaded before accessing `hassConnection` — check `typeof (await window.hassConnection).auth !== 'undefined'`.
- **HA History API returns data from BEFORE the specified start timestamp.** A query with `start_time=2026-07-09T04:15:00` returned records dating back to `02:15` — a full 2 hours earlier. This appears to be by design (the API returns the state at the start time plus all changes within the window, but the "state at start time" may be sourced from much earlier records). **Impact**: when doing time-windowed analysis (e.g. "what happened at 04:30?"), filter the results client-side by timestamp AFTER fetching, or the dataset will be polluted with irrelevant earlier data. Example: `hist.filter(h => h.last_changed.substring(11,16) >= '04:15')`.
- **History API `end_time` parameter does NOT reliably truncate results.** Even with `end_time` set, the API may return records beyond that timestamp. Always filter client-side for precise time windows.
@@ -0,0 +1,178 @@
# HA Dashboard Redesign — Brainstorming & Design
Session: 2026-07-10. User requested a complete dashboard redesign, mobile-first, with Mushroom Cards + Bubble Card navigation.
## Brainstorming Process
Used the `brainstorming` skill. Key decisions via clarifying questions:
1. **Goal**: Komplett-Redesign — alles neu, sauber strukturiert
2. **Primary device**: Handy (mobile-first, desktop is secondary)
3. **Top functions**: Energie (PV/Speicher/Marstek/Strompreis), Rollos, Licht
4. **Structure**: Ein Hauptdashboard + Energie separat (as before)
5. **Card approach**: Mix aus Mushroom + Bubble Navigation
## Entity Inventory (via browser console)
| Domain | Count | Key entities |
|--------|-------|-------------|
| light | 20 | Schlafzimmer, Esszimmer, Bad, Tradfri×7, Kinderzimmer×2, Garten×2, Deckenleuchte |
| cover | 10 | Wohnzimmerfenster×2, Wohnzimmertür, Esszimmertür, Küchenfenster×2, Badrollo, Terrassenrollo, Markise |
| climate | 12 | ViCare heating + 11 room thermostats (Bad, Büro, Esszimmer, FBH-WC, Flur unten, Gang OG, Wohnzimmer, Kinderzimmer Cleo, Schlafzimmer, Schminkzimmer, Windfang) |
| camera | 10 | Terrasse, Einfahrt, 6× robot vacuum maps, thumbnail |
| scene | 9 | Alles aus×2, Fernsehabend, Heizungen an/aus, Raffstore am Tag |
| lock | 5 | Haustür + others |
| media_player | 10 | Wohnzimmer, Harmony Hub + others |
| automation | 35 | Including Marstek Kaskaden-Steuerung |
| switch | 196 | Much chaff among them |
| sensor | 1128 | Heavily overloaded |
## Existing Dashboard State (pre-redesign)
| Dashboard | URL Path | Views | Cards (main) | Status |
|-----------|----------|-------|--------------|--------|
| Übersicht (default) | `lovelace` | 3 | 43+1+1 | ⚠️ Overloaded — cameras, blinds, 11 thermostats, media, energy, lights, sensors, scenes, weather, door contacts all on one view |
| Stromerzeugung/-verbrauch | `stromerzeugung-verbrauch` | 5 | 30 | 3 of 5 views empty (PV Erzeugung, Stromverbrauch, Kindle) |
| IT | `dashboard-it` | 1 | 16 | OK |
| Karte | `map` | 1 | 1 | Minimal |
| Editor | `dashboard-editor` | 1 | 1 | Tool (config-editor-card) |
| Übersicht (test) | `dashboard-test` | 1 | 0 | Empty — delete candidate |
| Haus | `dashboard-haus` | 0 | 0 | Empty — repurpose as new main dashboard |
| Typen | `dashboard-typen` | 0 | 0 | Empty — delete candidate |
| Home_Auto | `home-auto` | 0 | 0 | Empty — delete candidate |
| Moonraker | `dashboard-moonraker` | 0 | 0 | Empty — delete candidate |
## Installed Custom Cards (via HACS)
Already installed:
- `custom:power-flow-card-plus` — power distribution visualization
- `custom:price-timeline-card` — electricity price timeline
- `custom:config-editor-card` — dashboard editor
Needed for redesign:
- `mushroom-cards` — compact, touch-friendly UI elements (HACS)
- `bubble-card` — bottom-bar navigation, swipe between views (HACS)
## Approved Design
### Dashboard 1: "Haus" (Main Dashboard, mobile-first)
**Navigation:** Bubble Card Bottom-Bar with 5 tabs
```
🏠 Home | ☀️ Rollos | 💡 Licht | 🔒 Sicherheit | 🌡️ Klima
```
**Tab 1 — Home (Minimal):**
- Mushroom Title Card "Schön Home"
- Mushroom Template Card: Weather + outside temp
- Power-Flow-Card-Plus (mini energy flow: PV ↔ Battery ↔ House ↔ Grid)
- 2× Mushroom Entity Card: Presence (Dominik, Sarah)
- 1× Mushroom Chips Card: Door open/closed, Marstek SoC, electricity price — quick-info badges
**Tab 2 — Rollos (10 covers, grouped by floor):**
- Mushroom Cover Card per cover (large touch buttons: up/stop/down)
- Ground floor: Wohnzimmer×2, Esszimmer, Küche×2, Terrasse, Markise
- Upper floor: Bad, Schlafzimmer
- Master "Alle Rollos" card at top
**Tab 3 — Licht (20 lights + 9 scenes):**
- Mushroom Light Card per room (group where possible)
- Scenes as Mushroom Action Buttons at bottom: "Alles aus", "Fernsehabend", "Raffstore am Tag"
**Tab 4 — Sicherheit:**
- 2 Camera Cards (Terrasse, Einfahrt) — Picture Glance, compact
- Binary sensors: door/window contacts as Mushroom Entity Cards
- Lock: front door as Mushroom Lock Card
**Tab 5 — Klima (12 thermostats):**
- Mushroom Climate Card per room
- ViCare Heating + hot water at top
- Outside temperature chip
### Dashboard 2: "Energie" (existing, upgraded)
Remains separate. Fill the 3 empty views:
- **PV Erzeugung:** Apexcharts (daily/weekly curves SMA)
- **Stromverbrauch:** Apexcharts (consumer breakdown: heat pump, household, wallbox)
- **Kindle:** Black/white E-Ink optimized dashboard
### Cleanup
| Dashboard | Action |
|-----------|--------|
| dashboard-test (empty) | Delete |
| Haus (empty) | Repurpose → new main dashboard |
| Typen (empty) | Delete |
| Home_Auto (empty) | Delete |
| Moonraker (empty) | Delete |
| Editor | Keep (tool) |
| IT | Keep |
| Karte | Keep |
| Übersicht (lovelace, 43 cards) | Keep as backup after migration, then delete |
## Implementation Notes
- All dashboards use `mode: 'storage'` (UI-managed, not YAML files)
- Dashboard config write-back via WebSocket: `conn.sendMessagePromise({type: 'lovelace/config/save', url_path: '...', config: cfg})`
- Custom cards must be installed via HACS before referencing them in dashboard config
- Bubble Card bottom navigation requires a `bubble-card` config at the view level
## HACS Installation via WebSocket (confirmed working 2026-07-10)
HACS custom cards can be installed programmatically via the WebSocket API — no need to fight the HACS UI iframe:
```javascript
// 1. Find repository ID
const repos = await conn.sendMessagePromise({type: 'hacs/repositories/list'});
const mushroom = repos.find(r => r.full_name === 'piitaya/lovelace-mushroom');
// mushroom.id = '444350375'
// 2. Install
await conn.sendMessagePromise({type: 'hacs/repository/download', repository: mushroom.id});
// Returns {} on success
// 3. Verify resource registration
const resources = await conn.sendMessagePromise({type: 'lovelace/resources'});
// Look for /hacsfiles/lovelace-mushroom/mushroom.js in resources
```
Installed: Mushroom v5.1.1 (repo ID 444350375), Bubble Card v3.2.4 (repo ID 680112919).
## Entity Verification Pitfalls (encountered during implementation)
- **Scene name mismatch:** `scene.alles_aus` does NOT exist — the correct entity is `scene.alles_aus_2`. Always query `ha.hass.states` for scene entities before referencing.
- **Binary sensors lacking `device_class`:** Door/window binary sensors in this homelab do NOT have `device_class: door` or `device_class: window`. Filtering by `device_class` returns zero results. Instead, filter by entity name keywords: `tur`, `fenster`, `door`, `window`.
- **Available binary sensors (as of 2026-07-10):** `binary_sensor.wohnzimmerfenster2`, `binary_sensor.wohnzimmerture`, `binary_sensor.esszimmerture`, `binary_sensor.esszimmerture_2` — only 4, not the full set shown in the old overview dashboard.
- **Locks:** 5 lock entities exist: `lock.voreingestellte_tur` (main door), `lock.turoffner_1`, `lock.free_hometouch_7`, `lock.free_hometouch_7_2`, `lock.free_hometouch_7_3`.
## Post-Implementation Dashboard State (2026-07-10)
| Dashboard | URL Path | Views | Cards/view | Status |
|-----------|----------|-------|------------|--------|
| Haus (NEW) | `dashboard-haus` | 5 | 6/3/5/4/5 | ✅ Mushroom + Bubble, mobile-first |
| Stromerzeugung/-verbrauch | `stromerzeugung-verbrauch` | 5 | 30/1/4/4/1 | ✅ All views filled |
| Übersicht (backup) | `lovelace` | 3 | 43/1/1 | Kept as backup |
| IT | `dashboard-it` | 1 | 16 | Unchanged |
| Karte | `map` | 1 | 1 | Unchanged |
| Editor | `dashboard-editor` | 1 | 1 | Unchanged |
Deleted: dashboard-test, Typen, Home_Auto, Moonraker (4 empty ghost dashboards).
## Haus Dashboard View Structure (final)
| View | Path | Cards | Content |
|------|------|-------|---------|
| Home | `home` | 6 | Bubble popup, weather template, power-flow-card-plus, presence grid, chips (door/Marstek/SMA), bubble nav |
| Rollos | `rollos` | 3 | Bubble popup, 9 mushroom cover cards in 2-col grid, bubble nav |
| Licht | `licht` | 5 | Bubble popup, scene chips (5 scenes), 8 room lights in 2-col grid, 9 tradfri/misc lights in 3-col grid, bubble nav |
| Sicherheit | `sicherheit` | 4 | Bubble popup, 2 camera picture-glance, 2 lock cards + 4 door/window sensors in 2-col grid, bubble nav |
| Klima | `klima` | 5 | Bubble popup, ViCare climate card, WW + outside temp grid, 11 room climate cards in 2-col grid, bubble nav |
## Energie Dashboard Filled Views
| View | Path | Cards | Content |
|------|------|-------|---------|
| PV Erzeugung | `pv-erzeugung` | 4 | 3 apexcharts (PV delivery, SoC, Solcast forecast vs actual), entity stats |
| Stromverbrauch | `stromverbrauch` | 4 | 3 apexcharts (house consumption, battery discharge, sauna power), consumer entity list |
| Kindle | `kindle` | 1 | Vertical stack of 6 mushroom template cards (PV, Haus, SMA SoC, Marstek SoC, Strompreis, PV Einsparung) — black icons for E-Ink |
@@ -0,0 +1,218 @@
# Dead Integration Cleanup — Bulk Entity & Config Entry Removal
Session: 2026-07-12. Removed 3 dead integrations (HAME Energy, Spoolman, Kia UVO) totaling 194 entities from a HA 2026.7.1 instance with 2,282 entities (28.5% unavailable).
## Decision Framework: When to Remove vs Disable
| Situation | Action |
|-----------|--------|
| Integration has a config entry AND no MQTT discovery | `DELETE /api/config/config_entries/entry/{ENTRY_ID}` — entities auto-remove |
| Integration is MQTT-discovered (shares MQTT broker config entry) | Cannot delete broker entry — remove entities individually via WebSocket + clear MQTT discovery topics |
| Integration in `setup_error` state, unrecoverable (e.g. cloud API deprecated) | Delete config entry via REST API |
| Integration in `setup_retry` but device may recover | Disable, don't delete — `config_entries/disable` via WebSocket |
| Feature-Not-Supported unavailable entities (vacuum sub-entities) | Leave alone — normal behavior, not a defect |
## Step-by-Step Procedure
### Phase 1: Baseline Measurement
Always measure before making changes:
```python
import json, urllib.request
from collections import Counter
TOKEN = open("/tmp/ha_token.txt").read().strip()
HA_URL = "http://10.0.30.10:8123"
req = urllib.request.Request(f"{HA_URL}/api/states",
headers={"Authorization": f"Bearer {TOKEN}"})
states = json.loads(urllib.request.urlopen(req, timeout=10).read())
total = len(states)
unavail = [e for e in states if e['state'] == 'unavailable']
print(f"Total: {total}, Unavailable: {len(unavail)} ({len(unavail)/total*100:.1f}%)")
# Group unavailable by prefix for targeting
groups = Counter()
for e in unavail:
parts = e['entity_id'].replace('.', '_').split('_')
groups['_'.join(parts[:3])] += 1
for g, c in groups.most_common(20):
print(f" {g}: {c}")
```
### Phase 2: Identify Config Entry IDs
```python
entries = json.loads(urllib.request.urlopen(urllib.request.Request(
f"{HA_URL}/api/config/config_entries/entry",
headers={"Authorization": f"Bearer {TOKEN}"})).read())
# Find target entries
for e in entries:
dom = e.get('domain', '')
if dom in ['target_domain1', 'target_domain2']:
print(f"{e['entry_id']} | {dom} | {e.get('title')} | {e.get('state')}")
```
### Phase 3: Delete Config Entries (REST API)
Only for integrations with their OWN config entry (not MQTT-shared):
```python
def api(path, method="GET", data=None):
headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
req = urllib.request.Request(f"{HA_URL}{path}", headers=headers, method=method,
data=data.encode() if data else None)
return urllib.request.urlopen(req, timeout=10).read()
# Delete — returns {\"require_restart\": false} on success
result = api(f"/api/config/config_entries/entry/{entry_id}", method="DELETE")
```
### Phase 4: Remove MQTT-Discovered Entities (WebSocket)
MQTT-discovered entities share the broker's `config_entry_id`. You CANNOT delete the broker entry — it serves all MQTT devices. Remove entities individually:
```python
import asyncio, json, websockets
async def remove_entities(entity_ids):
ws = await websockets.connect("ws://10.0.30.10:8123/api/websocket",
max_size=20*1024*1024)
# Auth
await ws.recv() # auth_required
await ws.send(json.dumps({"type": "auth", "access_token": TOKEN}))
await ws.recv() # auth_ok
# Get entity registry
await ws.send(json.dumps({"id": 1, "type": "config/entity_registry/list"}))
entities = json.loads(await ws.recv()).get("result", [])
# Filter targets
targets = [e for e in entities
if any(t in e.get("entity_id", "").lower()
for t in ["hame", "spoolman"])]
# Remove each
removed = 0
for i, entity in enumerate(targets):
await ws.send(json.dumps({
"id": 2 + i,
"type": "config/entity_registry/remove",
"entity_id": entity["entity_id"]
}))
resp = json.loads(await ws.recv())
if resp.get("success"):
removed += 1
print(f"Removed: {removed}/{len(targets)}")
await ws.close()
asyncio.run(remove_entities([]))
```
**Key point:** `config/entity_registry/remove` IS supported via WebSocket (unlike many other registry commands). It returns `{\"success\": true}` on success.
### Phase 5: Clear MQTT Discovery Topics
Without this step, MQTT discovery topics will recreate the entities on next HA restart.
```python
def publish_mqtt(topic, payload="", retain=True):
data = json.dumps({"topic": topic, "payload": payload,
"retain": retain}).encode()
req = urllib.request.Request(
f"{HA_URL}/api/services/mqtt/publish",
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"},
data=data, method="POST")
urllib.request.urlopen(req, timeout=10)
# Get device identifiers from device registry
# identifiers field: [["mqtt", "hame_energy_device-mac-address"]]
# Discovery topic pattern: homeassistant/<domain>/<device_id>/config
domains = ["binary_sensor", "sensor", "switch", "number",
"select", "text", "button", "update"]
for dev_id in device_identifiers:
for domain in domains:
topic = f"homeassistant/{domain}/{dev_id}/config"
publish_mqtt(topic) # Empty retained payload clears the topic
```
**Pitfall:** The REST API `mqtt/publish` returns `[]` (empty array) on success, not a confirmation object. Check HTTP 200, not response body content.
### Phase 6: Stop Source HA Addons
**Critical and easy to miss:** After removing MQTT-discovered entities, the HA addon that bridges them to MQTT may still be running and will re-create the entities. Check and stop source addons:
```bash
# List all HA addons (via qm guest exec on VM 106)
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 \
"qm guest exec 106 -- sh -c 'ha addons'"
# Stop a specific addon
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 \
"qm guest exec 106 -- sh -c 'ha addons stop <slug>'"
```
Known addon-to-integration mappings:
- `1376549a_hm2mqtt` → HAME Energy entities (must stop after HAME cleanup)
- `ccab4aaf_frigate` → Conflicts with CT-based Frigate (stop if CT Frigate is primary)
### Phase 7: Verify
Re-run the Phase 1 measurement script. Compare before/after.
## Worked Example: 2026-07-12 Cleanup
| Integration | Type | Entities | Method |
|-------------|------|----------|--------|
| HAME Energy | MQTT-discovered | 81 entities, 3 devices | WebSocket entity_registry/remove + 37 MQTT topics cleared + hm2mqtt addon stopped |
| Spoolman | Own config entry | 117 entities (2 in registry) | REST DELETE config entry + WebSocket entity removal |
| Kia UVO | Own config entry | ~5 entities | REST DELETE config entry (setup_error state) |
### Results
| Metric | Before | After | Delta |
|--------|--------|-------|-------|
| Total entities | 2,282 | 2,088 | 194 |
| Unavailable | 650 (28.5%) | 456 (21.8%) | 194 |
| Unknown | 197 (8.6%) | 198 (9.5%) | +1 |
### After Frigate Recovery (same session)
| Metric | After Cleanup | After Frigate | Delta |
|--------|--------------|---------------|-------|
| Total entities | 2,088 | 2,088 | 0 |
| Unavailable | 456 (21.8%) | 373 (17.9%) | 83 |
| Frigate cameras | 0 online | 2 recording | +2 |
| Frigate sensors | 18 dead | 93 available | +93 |
### Remaining Unavailable After Cleanup
| Group | Count | Category | Action |
|-------|-------|----------|--------|
| x40_master + staubsauger_oben | ~196 | Feature-Not-Supported | Leave (normal) |
| Double Take | 13 | Defunct (no service exists) | Disappear on HA restart |
| TRADFRI Schlafzimmer | 16 | Stale IP / VLAN | Re-add via Config Flow |
| Weather Station | 5 | Battery? | Check sensors |
## Pitfalls Encountered
1. **MQTT-discovered entities have the MQTT broker's `config_entry_id`, not their own.** Deleting the broker entry would destroy ALL MQTT devices. Always check `domain` in the config entry — if it's `mqtt`, the entities are MQTT-discovered and must be removed individually.
2. **`config/device_registry/remove` may silently fail.** The WebSocket command `config/device_registry/remove_config_entry_from_device` and `config/device_registry/remove` do not reliably succeed for MQTT-discovered devices. Focus on entity removal + MQTT topic clearing — devices with no remaining entities are cleaned up by HA automatically over time.
3. **Entity count in registry ≠ entity count in states.** The entity registry may contain more entries than `/api/states` shows (e.g., 81 HAME in registry vs 77 in states — some were already disabled/removed but kept in registry).
4. **`require_restart: false` from config entry deletion is misleading.** While the config entry is removed immediately, orphaned entities may linger in `/api/states` until HA's periodic cleanup runs. They disappear from the entity registry instantly but may show as `unavailable` in states for a short period.
5. **Deriving MQTT discovery topics from device registry identifiers requires guessing the topic format.** The `identifiers` field in the device registry (e.g., `[\"mqtt\", \"hame_energy_device-mac-address\"]`) gives the device identifier, but the actual discovery topic may use different separators or casing. Clear multiple variations to be thorough.
6. **MQTT-bridging HA addons must be stopped, not just entities removed.** After cleaning up HAME Energy MQTT-discovered entities, the `hm2mqtt` HA addon (`1376549a_hm2mqtt`) was still running and would re-discover them on next MQTT cycle. Always check for and stop the source addon: `qm guest exec 106 -- sh -c 'ha addons stop <slug>'`. Similarly, a conflicting Frigate HA addon (`ccab4aaf_frigate`) was in `error` state alongside the CT-based Frigate — stop it to avoid port/MQTT conflicts.
7. **HA addon inventory is accessible via `ha addons` CLI through qm guest exec.** The HA Supervisor REST API requires a supervisor token that differs from the normal HA long-lived token. Instead, use: `ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 "qm guest exec 106 -- sh -c 'ha addons'"` — returns YAML list of all addons with slug, name, state, and version.
@@ -0,0 +1,133 @@
# Home Assistant History/Recorder Troubleshooting
## Symptom: History page shows no data or is blank
### Quick Diagnosis Flow
1. **Check if recorder is running**
```
curl -s -H "Authorization: Bearer $HA_TOKEN" \
https://homeassistant.familie-schoen.com/api/services/recorder |
jq '.[].service'
```
If recorder service is missing, the `recorder` integration may be disabled.
2. **Check HA core logs for recorder errors**
```
curl -s -H "Authorization: Bearer $HA_TOKEN" \
https://homeassistant.familie-schoen.com/api/error_log
```
Look for: `sqlite3.DatabaseError`, `database is locked`, `disk I/O error`,
`Recorder thread encountered an error`, `purge` failures.
3. **Check disk space** (via SSH or HA API)
- HA OS: `ha os info` or check Settings → System → Storage in UI
- Docker/container: `df -h` inside the container
- Recorder stops writing when partition is >95% full
4. **Check database size**
- SQLite default: `/config/home-assistant_v2.db`
- If >2GB on SQLite, consider switching to PostgreSQL or MySQL
- Large DB causes slow queries → history appears empty (timeout)
5. **Check recorder config in configuration.yaml**
```yaml
recorder:
purge_keep_days: 10
db_url: sqlite:///config/home-assistant_v2.db
include:
domains:
- sensor
- switch
exclude:
domains:
- automation
```
Overly restrictive `include`/`exclude` filters can hide entities from history.
### Common Causes (ranked by frequency)
| Cause | Fix |
|-------|-----|
| Recorder DB corrupt | Stop HA, delete `.db` + `.db-wal` + `.db-shm`, restart |
| Disk full | Free space, run `recorder.purge` service |
| Recorder stuck on purge | Call `recorder.disable` then `recorder.enable` |
| SQLite lock contention | Switch to external DB (MySQL/PostgreSQL) |
| `history:` integration disabled | Add `history:` to configuration.yaml |
| Too many entities recorded | Add `exclude` filters, reduce retention |
| Time zone mismatch | Check `time_zone:` in configuration.yaml |
### Useful API Endpoints
```
# Get states for an entity (last 24h)
GET /api/history/period/{timestamp}?filter_entity_id=sensor.xxx
# Get all entity states now
GET /api/states
# Error logs
GET /api/error_log
# Call service
POST /api/services/recorder/purge
POST /api/services/recorder/disable
POST /api/services/recorder/enable
# HA config check
GET /api/config/core/check_config
```
### SSH Access
HA is VM 106 on proxmox1 (10.0.20.10). Access via Proxmox guest agent:
```bash
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 \
"qm guest exec 106 --timeout 30 -- bash -c 'docker logs homeassistant 2>&1 | tail -30'"
```
Config file: `/mnt/data/supervisor/homeassistant/configuration.yaml` (inside VM).
### External Database (MariaDB Galera + MaxScale)
Production HA uses MariaDB Galera cluster (3 nodes) behind MaxScale VIP, NOT SQLite.
If recorder errors mention MySQL/MariaDB (`MySQLdb.OperationalError`), see:
**`references/galera-cluster-recovery.md`** for full cluster recovery procedure.
Quick checks:
```bash
# MaxScale server states
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.81 "maxctrl list servers"
# Direct DB connection test via MaxScale
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.81 \
"mariadb -h 127.0.0.1 -P 3306 -u ha_recorder -p'<password>' --skip-ssl -e \"SELECT 1\""
# Galera node status
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.71 \
"sudo mariadb -e \"SHOW GLOBAL STATUS LIKE 'wsrep_cluster_size'; SHOW GLOBAL STATUS LIKE 'wsrep_local_state_comment';\""
```
Common Galera failure symptoms:
- All nodes `failed` since same timestamp → simultaneous crash (network or power)
- `Access denied` from HA → MaxScale can't route to any backend
- `Lost connection during query` → MaxScale routed to Donor/Desynced node without `available_when_donor=true`
### Recovery Procedure (corrupt SQLite DB)
```bash
# Via SSH to HA
ha core stop
mv /config/home-assistant_v2.db /config/home-assistant_v2.db.bak
rm -f /config/home-assistant_v2.db-wal /config/home-assistant_v2.db-shm
ha core start
```
History will rebuild from scratch. Old history is lost (backup exists as .bak).
### Prevention
- Set `recorder.commit_interval: 1` (flush more frequently, smaller WAL)
- Add `exclude` filters for noisy domains (automation, script, timer)
- Consider external DB for installations with 500+ entities
- Monitor disk space with a sensor/alert
@@ -0,0 +1,187 @@
# HA Energy/Power Analysis Methodology
How to diagnose unexpected power consumption (e.g. nighttime grid draw) using HA history data. Based on a session diagnosing ~200W nighttime grid import caused by a heat pump water heating cycle.
## Sensor Inventory (This Homelab)
### Grid / Net Power
| Entity | Name | Notes |
|--------|------|-------|
| `sensor.evcc_grid_power` | Leistung Netz | EVCC aggregate grid power (W). Positive = import. |
| `sensor.netzfluss_saldo` | Tibber Pulse Netzfluss Saldo | Tibber Pulse grid balance (W). Negative = export. Higher resolution than EVCC. |
| `sensor.stromverbrauch_haus_netto` | Stromverbrauch Haus netto | Net house power consumption (W) |
### Home / Load
| Entity | Name | Notes |
|--------|------|-------|
| `sensor.evcc_home_power` | Leistung Heim | Total home consumption (W) |
| `sensor.stromverbrauch_rest` | Stromverbrauch "Rest" | Unknown/rest consumption |
### PV
| Entity | Name | Notes |
|--------|------|-------|
| `sensor.evcc_pv_power` | Leistung PV | Aggregate PV power (W) |
| `sensor.hm_1500_power` | HM-1500 Power | Hoymiles HM-1500 via OpenDTU/MQTT |
| `sensor.schuppen_string_1_power` | Schuppen 1 Power | Hoymiles Schuppen 1 |
| `sensor.schuppen_2_power` | Schuppen 2 Power | Hoymilles Schuppen 2 |
### Batteries
| Entity | Name | Notes |
|--------|------|-------|
| `sensor.evcc_battery_power` | Leistung Batterie | EVCC aggregate battery (W). Positive = discharging. |
| `sensor.evcc_battery_soc` | Batterie Ladezustand | EVCC aggregate SOC (%) |
| `sensor.sn_3007105790_battery_power_discharge_total` | SMA Battery Discharge | SMA SunnyBoyStorage discharge (W) |
| `sensor.sn_3007105790_battery_power_charge_total` | SMA Battery Charge | SMA SunnyBoyStorage charge (W) |
| `sensor.sn_3007105790_battery_soc_total` | SMA Battery SOC | SMA SunnyBoyStorage SOC (%) |
| `sensor.marstek_venus_modbus_batterieleistung` | Marstek Batterieleistung | Marstek Venus (W). Negative = discharging. |
| `sensor.marstek_venus_modbus_ac_leistung` | Marstek AC-Leistung | Marstek AC output (W) |
| `sensor.marstek_venus_modbus_batterie_ladezustand` | Marstek SoC | Marstek Venus SOC (%) |
| `sensor.marstek_target_power` | Marstek Target Power | EVCC/setpoint target for Marstek (W) |
| `sensor.sma_target_power` | SMA Target Power | EVCC/setpoint target for SMA (W) |
### Large Consumers (Individual)
| Entity | Name | Notes |
|--------|------|-------|
| `sensor.shellypro3em_08f9e0e98b9c_total_active_power` | Wärmepumpe Total | Heat pump total active power (W). **Major consumer — up to 5.4kW.** |
| `sensor.strommessung_sauna_total_active_power` | Sauna Total | Sauna total active power (W) |
| `sensor.cu401b_s_verdichterleistung` | CU401B Verdichterleistung | Heat pump compressor power (kW) |
| `sensor.cu401b_s_verdichtermodulation` | CU401B Verdichtermodulation | Heat pump compressor modulation (%) |
| `sensor.cu401b_s_kompressorphase` | CU401B Kompressorphase | Compressor phase: off/preparing/heating/pause |
| `sensor.cu401b_s_ww_speichertemperatur` | WW-Speichertemperatur | Hot water tank temp (°C). Target: 46°C, Hysteresis: 4K |
| `sensor.ev_charger_schon_leistung_ladestation` | EV Charger Leistung | SMA EV Charger station power (W) |
| `sensor.evcc_wallbox_charge_power` | Wallbox Ladeleistung | EVCC wallbox charge power (kW) |
| `sensor.trockner_device_power` | Trockner Power | Dryer power (W) |
| `sensor.samsung_qn90aa_65_tv_power` | Samsung TV Power | TV power (W) |
### EVCC Config / Tariff
| Entity | Name | Notes |
|--------|------|-------|
| `number.evcc_residual_power` | Restleistung | EVCC residual power target (W). Default 100W. |
| `sensor.evcc_tariff_grid` | Kosten Netz | Current grid price (€/kWh) |
| `binary_sensor.evcc_battery_grid_charge_active` | Hausbatterie Netzladen | Whether battery is charging from grid |
### Heat Pump Config (ViCare / CU401B S)
| Entity | Name | Notes |
|--------|------|-------|
| `climate.vicare_heating` | CU401B S Heizung | Climate entity. Presets: comfort/eco/home/sleep |
| `number.cu401b_s_ww_temperatur` | WW-Temperatur | WW target temp (°C). Currently 46. |
| `number.cu401b_s_ww_hystereseschalter_ein` | WW-Hysterese ein | WW hysteresis ON (K). Currently 4. |
| `number.cu401b_s_ww_hystereseschalter_aus` | WW-Hysterese aus | WW hysteresis OFF (K). Currently 4. |
| `automation.komfort_heizung` | Komfort Heizung | Automation controlling heating comfort mode |
## Analysis Workflow
1. **Confirm the anomaly**: Query `sensor.evcc_grid_power` history for the suspected time window. Summarize in 30-min buckets to see the pattern. **Important**: Ask the user for approximate time — they may point to a different window than the first obvious spike. In one session, the first analysis found a 02:47 heat pump spike, but the user clarified the issue was at 04:30+, revealing a second smaller spike that was initially overlooked.
2. **Decompose the power equation**: At any moment: `grid = home - pv - battery_discharge + battery_charge`. Query all four simultaneously to see which component is causing the imbalance.
3. **Check battery SOC trajectory**: If batteries drain to low SOC during the night, they can't cover the remaining demand → grid import kicks in. Plot SOC over time for both batteries.
4. **Identify the triggering consumer**: Query individual large-consumer sensors (heat pump, sauna, EV charger, dryer) for the same time window. Look for spikes or sustained elevated consumption.
5. **Correlate with heat pump phases**: The `cu401b_s_kompressorphase` and `cu401b_s_verdichtermodulation` entities show exactly when the compressor starts, what phase it's in, and how hard it's working. The `ww_speichertemperatur` shows whether it's a hot-water heating cycle (temp rising toward target).
6. **Check EVCC control targets**: `marstek_target_power` and `sma_target_power` show what EVCC is commanding the batteries to do. If these are 0, EVCC isn't actively discharging the batteries — they may be in passive mode.
7. **Summarize findings**: Present a timeline table showing the key events and a root-cause explanation, followed by prevention options.
### Multiple Spikes Per Night (2026-07-09 Session)
In one session, two distinct grid import spikes were found in a single night:
| Time | Grid Peak | Home Peak | Heat Pump | Cause |
|------|-----------|-----------|-----------|-------|
| 02:47 | 4,808 W | 6,151 W | 5,440 W | Heat pump WW heating (compressor 47-50% modulation, WW temp 38→47°C) |
| 04:30 | 3,570 W | 4,920 W | 91 W (standby) | **Unknown** — heat pump was idle. Possibly a second WW cycle or another consumer not covered by monitored sensors. |
**Lesson**: Don't assume the first spike is the only one. Query the full 02:0007:00 window and look for ALL grid import periods >100W. The 04:30 spike showed home power at 4,920W but the heat pump was only drawing 91W — the 4,800W delta came from an unmonitored consumer. Future analysis should query ALL available power sensors (including `sensor.stromverbrauch_haus_netto` which aggregates differently from `evcc_home_power`) and consider unmonitored circuits.
## Prevention Strategies for Nighttime Grid Import
### Heat Pump WW Timing
The most impactful fix: shift hot water preparation to daytime PV hours.
- **ViCare schedule**: Set WW heating to only occur between 10:0015:00 (main PV production window).
- **Increase hysteresis**: Change `ww_hystereseschalter_ein` from 4K to 6K → starts later, fewer cycles.
- **Lower WW target**: Reduce `ww_temperatur` from 46°C to 43°C → less energy per cycle.
### Battery SOC Management
Ensure batteries start the night at 100%:
- **EVCC priority SOC**: Ensure `evcc_priority_soc` is set appropriately so batteries reserve enough for overnight.
- **Force charge before sunset**: If PV forecast predicts low production, trigger battery charging from PV surplus in late afternoon.
- **Marstek discharge limit**: `number.marstek_venus_modbus_entladeleistung_einstellen` controls max discharge rate. Lowering it extends battery runtime but may not fully cover peaks.
### Combination Approach (Recommended)
Both WW timing AND battery management together provide the most robust solution.
## EVCC-Side Battery Discharge Diagnosis
When the question shifts from "what consumed the power?" to "why didn't the battery cover the gap?", the EVCC `api/state` endpoint provides the answer. This is a different diagnostic axis from the consumption-side analysis above.
### Key Fields in `curl -s http://10.0.30.10:7070/api/state`
| Field | Path | Meaning |
|-------|------|---------|
| Controllable | `battery.devices[].controllable` | `false` = EVCC reads SoC/power but cannot command discharge |
| Discharge control | `batteryDischargeControl` | `false` = passive monitoring, no active discharge management |
| Residual power | `residualPower` | Watts EVCC tolerates from grid before reacting (default 100) |
| Battery mode | `batteryMode` | `unknown` = no active mode set |
| Per-battery power | `battery.devices[].power` | Current output (W) — compare to home power |
| Per-battery SOC | `battery.devices[].soc` | High SOC + low output = battery is limited, not empty |
### Diagnosis Flow
1. **Check `controllable`** — if `false`, EVCC cannot increase discharge. The battery's own internal limit is the bottleneck.
2. **Check `residualPower`** — if >0, EVCC deliberately allows that much grid import as baseline.
3. **Compare battery output to home power** — if battery output << home power and SOC is high, the battery's internal discharge limit (not EVCC) is the constraint.
4. **Verify by checking spike behavior** — if the battery briefly delivered much more during a spike (e.g. Marstek hit 1,437W during heat pump cycle but settled to ~650W steady-state), that confirms an internal limiter, not a capacity issue.
### Case Study: Marstek Venus Not Covering 200W Gap (2026-07-10)
**Symptom**: ~200W continuous grid import 03:0004:00 despite Marstek SoC at 73%.
**Initial hypothesis (INCOMPLETE)**: EVCC `controllable: false` + `residualPower: 100` + Marstek internal discharge limit ≈ 650W.
**Actual root cause**: The HA automation "Marstek Kaskaden-Steuerung (Modbus)" (`automation.marstek_modbus_steuerung`) controls the Marstek, NOT EVCC. EVCC sees `controllable: false` because it's only a passive observer. The automation has a **feedback-loop bug** in its `echter_bedarf` formula:
```yaml
# BUG: uses setpoint (number.*_einstellen) instead of measured (sensor.*)
echter_bedarf: "{{ power_home - production + marstek_current_discharge - marstek_current_power }}"
# marstek_current_discharge = states('number.marstek_venus_modbus_entladeleistung_einstellen') ← SETPOINT
```
This causes the discharge setpoint to spiral: 0 → 870 → 1740 → 2500 (capped) within 3 cycles (6 min). The Marstek's `anti_feed` mode then throttles actual output to ~650W (preventing grid export from the oversized command). The ~200W gap is the difference between what the automation commands (2500W) and what the Marstek actually delivers after anti_feed throttling (~650W), versus what the house needs (~870W).
**Evidence**: During the 02:4302:57 heat pump spike, Marstek delivered up to 1,437W (proving capacity exists), but settled back to ~650W afterward — consistent with the automation ramping the setpoint up and anti_feed throttling back.
**Fix**: Replace setpoint variables with measured AC power in the automation:
```yaml
# FIXED:
marstek_actual_ac: "{{ states('sensor.marstek_venus_modbus_ac_leistung') | float(0) }}"
echter_bedarf: "{{ power_home - production - marstek_actual_ac }}"
```
Result: `echter_bedarf = 870 - 0 - 650 = 220W` → new setpoint = 650 + 220 = 870W. Stable convergence.
See `references/marstek-cascade-automation.md` for the full automation YAML and analysis.
## Query Templates
See `references/ha-browser-console-access.md` for the browser-console technique used to execute these queries when no local API token is available.
### Summary query (all sensors, 30-min buckets)
```javascript
// Adapt entity list and time range as needed
const entities = 'sensor.evcc_grid_power,sensor.evcc_home_power,sensor.evcc_battery_power,sensor.evcc_pv_power';
const url = '/api/history/period/2026-07-09T03:00:00?filter_entity_id=' + entities +
'&end_time=2026-07-09T06:00:00&minimal_response';
```
### Individual consumer spike detection
```javascript
// Filter history records where power > threshold
const spikes = hist.filter(h => parseFloat(h.state) > 100).map(h => ({
t: h.last_changed.substring(11, 16),
v: parseFloat(h.state)
}));
```
@@ -0,0 +1,71 @@
# HA Health Audit — 2026-07-12
## Snapshot
- **HA Version**: 2026.7.1 (RUNNING)
- **Total entities**: 2,282
- **Unavailable**: 650 (28.5%)
- **Unknown**: 197 (8.6%)
- **Automations**: 35 total (25 on, 7 off)
- **Components**: 442
- **Config entries**: 152 loaded, 5 setup_retry, 13 not_loaded, 1 setup_error
## Top Unavailable Groups
| Group | Count | Root Cause |
|-------|-------|------------|
| sensor_spoolman_spool | 101 | Spoolman server down/not configured |
| select_x40_master | 85 | Feature-Not-Supported (normal for Dreame vacuum) |
| select_staubsauger_oben | 48 | Feature-Not-Supported (normal for Dreame vacuum) |
| text_hame_energy | 30 | HAME Energy integration replaced by Marstek Modbus, not cleaned up |
| sensor_hame_energy | 22 | Same — dead integration |
| sensor_double_take | 13 | Depends on Frigate (CT 120 down) |
| number_x40_master | 12 | Feature-Not-Supported |
| number_hame_energy | 12 | Dead integration |
| number_staubsauger_oben | 11 | Feature-Not-Supported |
| switch_x40_master | 10 | Feature-Not-Supported |
| switch_hame_energy | 10 | Dead integration |
| sensor_tradfri_schlafzimmer | 10 | Bulbs unreachable |
| binary_sensor_terrasse | 9 | Frigate-dependent |
| binary_sensor_einfahrt | 9 | Frigate-dependent |
| light_tradfri_bulb | 6 | TRADFRI gateway stale IP |
## Setup Retry Entries
| Domain | Title | Source |
|--------|-------|--------|
| lifx | Kinderzimmer 1 | dhcp |
| lifx | Kinderzimmer 2 | dhcp |
| tradfri | 192.168.100.60 | user (stale pre-migration IP!) |
| frigate | 10.0.30.104:5000 | user |
| ollama | http://10.0.30.98:11434 | user |
## Setup Error
| Domain | Title |
|--------|-------|
| kia_uvo | Hyundai Europe dominik@familie-schoen.com |
## Not Loaded (13, all `ignore` source)
nest, dlna_dms (AVM FRITZ!Mediaserver), upnp (FRITZ!Box 7590 ×2), homekit_controller (×3 bridges), battery_notes (×3), template (Tibber Avg Price)
## Automations OFF
| Entity ID | Friendly Name |
|-----------|---------------|
| automation.warmwasserbereitung_an | DHW onetimecharge |
| automation.dhw_onetimecharge_when_dhw_charging_active | DHW onetimecharge when DHW Charging active |
| automation.tibber_charge_battery | Tibber Charge Battery |
| automation.vogelabwehr_1 | Vogelabwehr 1 |
| automation.vogelabwehr_2 | Vogelabwehr 2 |
| automation.vogelabwehr_3 | Vogelabwehr 3 |
| automation.emhass_batterie_kaskadensteuerung_sma_marstek | EMHASS: Batterie-Kaskadensteuerung (SMA & Marstek) |
## Recommended Cleanup Priorities
1. **Delete dead integrations**: HAME Energy (77 entities) + Spoolman (116 entities) + Kia UVO → ~194 entities removed
2. **Recover Frigate**: CT 120 check + restart → restores Double Take + camera entities
3. **Reactivate automations**: Marstek cascade (bug already fixed), Tibber battery, warm water
4. **Network migration cleanup**: TRADFRI stale IP 192.168.100.60, LIFX DHCP discovery
5. **Disable vacuum feature-bloat entities**: ~245 unavailable x40_master + staubsauger_oben sub-entities
@@ -0,0 +1,201 @@
# HA Proactive Health Audit Methodology
Systematic health assessment of a Home Assistant instance — not tied to a specific outage, but used to find improvement opportunities, dead integrations, and hidden issues. Complements the reactive "Bulk Entity Audit" section in the SKILL.md (which diagnoses a *known* outage). This procedure finds problems the user doesn't know about yet.
Based on a 2026-07-12 audit session that reduced 28.5% unavailable entities to a prioritized action list.
## Audit Procedure (6 Steps)
### Step 1: Instance Metadata
```bash
curl -s "$HA_URL/api/config" -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys,json
d=json.load(sys.stdin)
print(f'Version: {d.get(\"version\",\"?\")}')
print(f'State: {d.get(\"state\",\"?\")}')
comps=d.get('components',[])
print(f'Components: {len(comps)}')
"
```
Records HA version, running state, and component count. Useful for tracking growth over time and confirming the instance is responsive.
### Step 2: Entity Health Statistics
```python
import json
from collections import Counter
data = json.loads(open('/tmp/ha_states.json','rb').read().decode('utf-8','replace'))
total = len(data)
unavail = [e for e in data if e['state'] == 'unavailable']
unknown = [e for e in data if e['state'] == 'unknown']
print(f'Total entities: {total}')
print(f'Unavailable: {len(unavail)} ({len(unavail)/total*100:.1f}%)')
print(f'Unknown: {len(unknown)} ({len(unknown)/total*100:.1f}%)')
# Domain distribution
domains = Counter(e['entity_id'].split('.')[0] for e in data)
print(f'Domains (top 15): {domains.most_common(15)}')
```
**Benchmark**: <5% unavailable is healthy. 10-20% suggests stale integrations. >25% indicates systemic issues (dead integrations, network migration remnants, or a recent outage that didn't fully recover).
### Step 3: Group Unavailable Entities by Source
This is the key diagnostic step — group unavailable entities by their integration/device prefix to identify which integrations are responsible for the bulk of unavailability.
```python
groups = Counter()
for e in unavail:
eid = e['entity_id']
parts = eid.replace('.', '_').split('_')
key = '_'.join(parts[:3]) # e.g. "sensor_spoolman_spool"
groups[key] += 1
for g, c in groups.most_common(20):
print(f' {g}: {c}')
```
**Interpretation**: Groups with 50+ unavailable entities indicate an entire integration is down, not individual device failures. Common patterns:
- `sensor_spoolman_*` → Spoolman server offline
- `*_hame_energy` → HAME Energy integration dead (often replaced by Modbus)
- `select_x40_master` / `select_staubsauger_oben` → Vacuum "feature-not-supported" entities (normal, but bloat)
- `*_double_take` → Face recognition dependent on Frigate (cascade failure)
### Step 4: Config Entry State Audit
```bash
curl -s "$HA_URL/api/config/config_entries/entry" -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys,json
from collections import Counter
data=json.load(sys.stdin)
states=Counter(e.get('state','?') for e in data)
print(f'Config entry states: {dict(states)}')
for state in ['setup_retry','setup_error']:
entries=[e for e in data if e.get('state')==state]
for e in entries:
print(f' {e.get(\"domain\")} | {e.get(\"title\")} | {e.get(\"source\")}')
"
```
**States**:
| State | Meaning | Action |
|-------|---------|--------|
| `loaded` | Working | None |
| `setup_retry` | Discovered but can't connect | Check device/network, reconfigure |
| `setup_error` | Configuration failed | Check credentials, re-setup |
| `not_loaded` | Disabled/ignored | Clean up if no longer needed |
**Common findings**:
- Stale IPs from network migrations (e.g. `192.168.100.x` titles when network is now `10.0.x`)
- `not_loaded` entries from abandoned integrations (Nest, DLNA, UPnP, HomeKit bridges)
- `setup_retry` from VLAN-boundary mDNS failures (see SKILL.md "VLAN/Network Segmentation Diagnosis")
### Step 5: Automation Status
```python
autos = [e for e in data if e['entity_id'].startswith('automation.')]
on_a = sum(1 for a in autos if a['state'] == 'on')
off_a = sum(1 for a in autos if a['state'] == 'off')
print(f'Automations: {len(autos)} total, {on_a} on, {off_a} off')
# List disabled ones with friendly names
for a in autos:
if a['state'] == 'off':
name = a.get('attributes',{}).get('friendly_name', a['entity_id'])
print(f' {a["entity_id"]}: {name}')
```
Disabled automations are often forgotten after debugging. Check if they were disabled intentionally or left off after a fix was applied but never re-enabled. **Especially check automations that were recently fixed** — the fix may have been applied but the automation never reactivated.
### Step 6: Staleness Detection
```python
import datetime
now = datetime.datetime.now(datetime.timezone.utc)
stale = []
for e in data:
lc = e.get('last_changed', '')
if not lc:
continue
try:
dt = datetime.datetime.fromisoformat(lc.replace('Z', '+00:00'))
age = (now - dt).total_seconds() / 3600
if age > 24 and e['state'] not in ('unavailable', 'unknown'):
stale.append((e['entity_id'], e['state'], round(age)))
except:
pass
stale.sort(key=lambda x: x[2], reverse=True)
print(f'Stale entities (>24h, not unavail/unknown): {len(stale)}')
```
Large stale counts (hundreds) often correlate with `update.*` entities that haven't been polled since last restart, or sensors from integrations that silently stopped publishing. Cross-reference with Step 3 groups.
## Synthesis: Prioritized Improvement List
Combine all 6 steps into a ranked table:
| Priority | Issue | Entities Affected | Effort | Rationale |
|----------|-------|--------------------|--------|-----------|
| 1 | Dead integration removal (HAME, Spoolman, Kia) | 194 | S | Largest reduction in unavailable count |
| 2 | Frigate recovery chain | 13+ | M | Security cascade dependency |
| 3 | Reactivate disabled automations | 7 | S | Immediate functional gain |
| 4 | Network migration cleanup | 10+ | S | Stale IPs blocking reconnection |
| 5 | Entity bloat reduction | 245+ | M | Disable unsupported vacuum entities |
**Scoring criteria** (adapted from `ideate` skill):
- **Impact** (30%): How many entities/users are affected?
- **Feasibility** (25%): Can it be done without breaking production?
- **Strategic fit** (20%): Does it align with the user's direction (e.g. Marstek replacing HAME)?
- **Effort** (15% inversely weighted): S < 30 min, M < 2h, L > 2h
- **Compound potential** (10%): Does fixing this unlock other improvements?
## Audit Results Snapshot (2026-07-12)
| Metric | Value |
|--------|-------|
| HA Version | 2026.7.1 |
| Total entities | 2,282 |
| Unavailable | 650 (28.5%) |
| Unknown | 197 (8.6%) |
| Automations | 35 (25 on, 7 off) |
| Config entries | 152 loaded, 5 setup_retry, 13 not_loaded, 1 setup_error |
| Stale (>24h) | 680 |
### Top Unavailable Groups
| Group | Count | Root Cause |
|-------|-------|------------|
| `sensor_spoolman_spool` | 101 | Spoolman server down |
| `select_x40_master` | 85 | Vacuum feature-not-supported (normal bloat) |
| `select_staubsauger_oben` | 48 | Same |
| `text/sensor/number/switch_hame_energy` | 74 | HAME Energy completely dead (replaced by Marstek Modbus) |
| `sensor_double_take` | 13 | Frigate dependency (cascade) |
| `light_tradfri_*` | 16+ | Stale IP 192.168.100.60 (pre-migration) |
### Setup Retry Entries
| Domain | Title | Source |
|--------|-------|--------|
| lifx | Kinderzimmer 1 | dhcp |
| lifx | Kinderzimmer 2 | dhcp |
| tradfri | 192.168.100.60 | user |
| frigate | 10.0.30.104:5000 | user |
| ollama | http://10.0.30.98:11434 | user |
### Disabled Automations
| Automation | Friendly Name | Likely Reason |
|------------|---------------|---------------|
| `automation.warmwasserbereitung_an` | DHW onetimecharge | Disabled during tuning |
| `automation.dhw_onetimecharge_when_dhw_charging_active` | DHW onetimecharge when DHW Charging active | Related to above |
| `automation.tibber_charge_battery` | Tibber Charge Battery | Disabled during testing |
| `automation.vogelabwehr_1/2/3` | Vogelabwehr 1/2/3 | Seasonal? |
| `automation.emhass_batterie_kaskadensteuerung_sma_marstek` | EMHASS Kaskadensteuerung | Fixed but not reactivated! |
## Pitfalls
- **Error log endpoint may 404 on some HA versions.** `/api/error_log` returned 404 on HA 2026.7.1. Try `/api/error/all` or check via SSH instead. Don't treat the 404 as "no errors" — it means the endpoint isn't available, not that the log is empty.
- **Vacuum "feature-not-supported" entities inflate the unavailable count dramatically.** Two Dreame vacuums contributed ~250 unavailable entities. These are normal (the integration creates entities for all possible features, unsupported ones stay unavailable). Don't try to "fix" them — consider disabling them in the entity registry to reduce noise.
- **`update.*` entities dominate the stale list.** Most stale entities are `update.*` sensors that only change when an update is available. This is normal behavior, not a defect. Filter them out when assessing real staleness.
- **Config entry titles can be stale.** A `setup_retry` TRADFRI entry titled "192.168.100.60" doesn't mean the integration is trying that IP — the title is cosmetic. Check the actual `data` field in `.storage/core.config_entries` for the real configured address.
@@ -0,0 +1,106 @@
# Editing HA Integration Config Entries Directly
When a HA integration's configuration (host, port, credentials) needs updating
but the UI is unavailable or the integration was auto-discovered (zeroconf/ssdp),
you can edit the config entry storage file directly.
## File Location
```
/mnt/data/supervisor/homeassistant/.storage/core.config_entries
```
Accessed via Proxmox guest agent (HA is VM 106 on proxmox1):
```bash
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 \
"qm guest exec 106 --timeout 30 -- bash -c 'cat /mnt/data/supervisor/homeassistant/.storage/core.config_entries'"
```
## Format
JSON array of config entry objects. Each entry has:
```json
{
"entry_id": "5e1d2138...",
"domain": "nut",
"title": "192.168.100.27:3493",
"data": {
"host": "192.168.100.27",
"port": 3493
},
"source": "zeroconf",
"disabled_by": null,
...
}
```
## Editing Procedure
1. **Backup first**:
```bash
cp .../core.config_entries .../core.config_entries.bak
```
2. **Find the entry** — grep for the domain or old IP:
```bash
grep -A20 '"domain":"nut"' core.config_entries | head -25
```
3. **Replace the value** (sed):
```bash
sed -i 's/"host":"192.168.100.27"/"host":"10.0.30.100"/g' core.config_entries
```
4. **Verify the change**:
```bash
grep '"host":"10.0.30.100"' core.config_entries
```
5. **Restart HA** — changes to `.storage/` files only take effect after restart:
```bash
curl -s -X POST "$HA_URL/api/services/homeassistant/restart" \
-H "Authorization: Bearer $HA_TOKEN" \
-H "Content-Type: application/json" -d '{}'
```
6. **Verify after restart** — poll `GET /api/config` until `state: RUNNING`,
then check entity states for the affected integration.
## NUT/UPS Walkthrough (June 2026)
### Problem
NUT integration entities all `unavailable`. Config entry pointed to old IP
`192.168.100.27:3493` after network migration to `10.0.30.x`.
### Root Causes (3 separate issues!)
1. **NUT server `MODE=none`** in `/etc/nut/nut.conf` → upsd wouldn't start
- Fix: `sed -i 's/^MODE=none/MODE=netserver/' /etc/nut/nut.conf`
2. **upsd.conf bound to old IP** — `LISTEN 192.168.100.27 3493`
- Fix: `echo 'LISTEN 0.0.0.0 3493' > /etc/nut/upsd.conf`
3. **Duplicate `[monitor]` section** in `upsd.users` (two entries, conflicting)
- Fix: Rewrite with single merged `[monitor]` section
4. **HA config entry pointed to old IP**
- Fix: `sed -i 's/"host":"192.168.100.27"/"host":"10.0.30.100"/g' core.config_entries`
### Verification
```bash
# On NUT host:
upsc apc@localhost # Should return UPS variables
# From HA/remote:
python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('10.0.30.100', 3493)); print('OK')"
# In HA after restart:
curl -s "$HA_URL/api/states" -H "Authorization: Bearer $HA_TOKEN" | \
python3 -c "import sys,json; [print(f'{e[\"entity_id\"]}: {e[\"state\"]}') for e in json.load(sys.stdin) if 'apc' in e['entity_id'].lower()]"
```
### Key Entities After Fix
- `sensor.apc_akku_ladung`: 100 (battery charge %)
- `sensor.apc_batteriespannung`: 13.6 (battery voltage)
- `sensor.apc_eingangsspannung`: 244.0 (input voltage)
- `sensor.apc_status`: Online, Low Battery
- `sensor.apc_last`: 75 (load %)
⚠️ "Low Battery" at 100% charge with 60s runtime = aging battery (mfg date 2019/10).
Consider replacement.
@@ -0,0 +1,166 @@
# HA Lovelace Dashboard Enumeration via WebSocket API
Enumerating and reading HA Lovelace dashboard configurations requires the **WebSocket API**, not REST. The REST endpoints (`/api/config/dashboard/config`, `/api/lovelace/config`) return **404** — they do not exist.
## Listing All Dashboards
```javascript
(async () => {
const ha = document.querySelector("home-assistant");
const conn = ha.hass.connection;
const dashboards = await conn.sendMessagePromise({
type: 'lovelace/dashboards/list'
});
// Returns array of: { id, title, url_path, icon, mode, require_admin, show_in_sidebar }
return JSON.stringify(dashboards, null, 2);
})()
```
## Reading a Dashboard's Full Config (Views + Cards)
```javascript
(async () => {
const ha = document.querySelector("home-assistant");
const conn = ha.hass.connection;
// Use url_path (the URL slug), NOT the dashboard id
// e.g. 'dashboard-haus', 'stromerzeugung-verbrauch', 'lovelace' (default)
const cfg = await conn.sendMessagePromise({
type: 'lovelace/config',
url_path: 'dashboard-haus' // ← URL slug from dashboards/list
});
// Returns: { title, views: [{ title, path, icon, cards: [...], badges: [...] }] }
// Empty dashboards return { views: [] }
return JSON.stringify(cfg, null, 2);
})()
```
## Key Details
| Parameter | Correct key | Wrong key | Error if wrong |
|-----------|------------|-----------|----------------|
| Dashboard selector | `url_path` | `url` | `"extra keys not allowed @ data['url']"` |
| Dashboard value | URL slug (e.g. `dashboard-haus`) | Dashboard ID (e.g. `dashboard_haus`) | Various |
- The default overview dashboard uses `url_path: 'lovelace'`.
- `sendMessagePromise` is the async wrapper for WebSocket commands on `conn`. It returns a Promise that resolves to the response object.
- All dashboards in this homelab use `mode: 'storage'` (managed via UI, not YAML files).
- The REST API endpoints `/api/lovelace/config/{url}` and `/api/config/dashboard/config` both return 404. Only the WebSocket API works.
## Enumerating Card Types Across All Dashboards
Useful for auditing dashboard complexity (e.g. finding overloaded views):
```javascript
(async () => {
const ha = document.querySelector("home-assistant");
const conn = ha.hass.connection;
const dashboards = await conn.sendMessagePromise({type: 'lovelace/dashboards/list'});
const report = {};
for (const dash of dashboards) {
try {
const cfg = await conn.sendMessagePromise({
type: 'lovelace/config',
url_path: dash.url_path
});
const views = (cfg.views || []).map(v => ({
title: v.title,
path: v.path,
icon: v.icon,
cardCount: (v.cards || []).length,
cardTypes: (v.cards || []).map(c => c.type).filter(Boolean)
}));
report[dash.url_path] = { title: dash.title, views };
} catch(e) {
report[dash.url_path] = { error: String(e).substring(0, 100) };
}
}
return JSON.stringify(report, null, 2);
})()
```
## Writing Dashboard Config Back
To modify a dashboard, send the full config object back via WebSocket:
```javascript
(async () => {
const ha = document.querySelector("home-assistant");
const conn = ha.hass.connection;
// Read current config
const cfg = await conn.sendMessagePromise({
type: 'lovelace/config',
url_path: 'dashboard-haus'
});
// Modify (e.g. add a view, rearrange cards)
cfg.views.push({
title: 'New View',
path: 'new-view',
cards: [{ type: 'entities', entities: ['sensor.example'], title: 'Example' }]
});
// Save back
await conn.sendMessagePromise({
type: 'lovelace/config/save',
url_path: 'dashboard-haus',
config: cfg
});
return 'Saved';
})()
```
## This Homelab's Dashboard Inventory (as of 2026-07-10, post-redesign)
| Dashboard | URL Path | Views | Cards (main view) | Status |
|-----------|----------|-------|--------------------|--------|
| Haus (NEW) | `dashboard-haus` | 5 | 6/3/5/4/5 | ✅ Mushroom + Bubble, mobile-first |
| Stromerzeugung/-verbrauch | `stromerzeugung-verbrauch` | 5 | 30/1/4/4/1 | ✅ All views filled |
| Übersicht (backup) | `lovelace` | 3 | 43+1+1 | Old overview, kept as backup |
| IT | `dashboard-it` | 1 | 16 | OK |
| Karte | `map` | 1 | 1 | Minimal |
| Editor | `dashboard-editor` | 1 | 1 | Tool (config-editor-card) |
Deleted (2026-07-10): dashboard-test, Typen, Home_Auto, Moonraker (4 empty ghost dashboards).
Custom cards installed: `power-flow-card-plus`, `price-timeline-card`, `config-editor-card`, `platinum-weather-card`, `xiaomi-vacuum-map-card`, `apexcharts-card`, `kiosk-mode`, `mushroom` (v5.1.1), `bubble-card` (v3.2.4).
## Deleting Dashboards
```javascript
await conn.sendMessagePromise({
type: 'lovelace/dashboards/delete',
dashboard_id: 'dashboard_test' // ← underscore ID from dashboards/list
});
// Returns: null on success
```
**Key:** `dashboard_id` uses underscores (e.g. `dashboard_test`), while `lovelace/config` uses `url_path` with hyphens (e.g. `dashboard-test`). Always use `id` from the `dashboards/list` response for deletion.
## Listing Lovelace Resources (Custom Cards)
```javascript
const resources = await conn.sendMessagePromise({type: 'lovelace/resources'});
// Returns: [{id, url, type}]
// HACS auto-registers installed cards here. Example:
// /hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375511
// /hacsfiles/Bubble-Card/bubble-card.js?hacstag=680112919324
```
Use this to verify HACS installations landed correctly, or to audit which custom cards are available for dashboard configs.
## Pitfalls
- **REST API 404 for dashboard config.** Neither `/api/lovelace/config` nor `/api/config/dashboard/config` exist as REST endpoints. Use WebSocket `lovelace/config` with `url_path` parameter.
- **`url` vs `url_path` parameter name.** Using `url` instead of `url_path` causes `"extra keys not allowed @ data['url']"` error.
- **Empty dashboards return `{ views: [] }`, not an error.** Don't treat missing views as a failure — the dashboard exists but has no content.
- **Dashboard ID uses underscores, URL path uses hyphens.** `dashboard_haus` (id) → `dashboard-haus` (url_path). Always use `url_path` for `lovelace/config` queries, but `dashboard_id` (underscore form) for `lovelace/dashboards/delete`.
- **`sendMessagePromise` not `sendMessage`.** The plain `sendMessage` is fire-and-forget. Use `sendMessagePromise` to get the response.
- **Verify entity IDs before saving.** Query `ha.hass.states` for each entity ID before writing a dashboard config. Scene names may differ (`scene.alles_aus_2` not `scene.alles_aus`), binary sensors may lack `device_class` (filter by name keywords), and some sensors may be `unavailable`. Dead entity references produce "Entity not available" cards.
@@ -0,0 +1,105 @@
# HA Recorder Silent Connection Drop
## Symptom
HA shows no historical data (History/Logbook empty or stale), but HA itself
runs normally — entities update, automations fire, no error logs.
## Root Cause
HA Recorder loses its MySQL/Galera DB connection silently. Unlike the
exhausted-retries-at-boot case, no `ERROR (Recorder)` entries appear in
the HA log. The recorder thread is alive but disconnected — it never
reconnects on its own.
Observed triggers:
- MaxScale `FLUSH HOSTS` during Galera maintenance
- Galera node restart (brief connection interruption)
- MaxScale VM failover (VIP moves to standby)
## Diagnosis Procedure
### 1. Check HA logs for recorder errors
```bash
ssh -i ~/.ssh/id_ed25519_proxmox -p 22222 root@10.0.30.10 \
"docker logs homeassistant 2>&1 | grep -i recorder | tail -20"
```
If EMPTY — this is the silent-drop pattern (not the exhausted-retries case,
which floods the log with `ERROR (Recorder)` messages).
### 2. Check MaxScale for active HA sessions
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.82 "maxctrl list sessions"
```
If `ha_recorder` is NOT listed among active sessions, the recorder
connection is dead. Only `recipe_app` or other apps may be connected.
### 3. Check `recorder_runs` table on Galera
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.73 \
"sudo mariadb homeassistant -e 'SELECT run_id, start, \`end\` FROM recorder_runs ORDER BY run_id DESC LIMIT 3\G'"
```
An open run (`end = NULL`) with an old `start` timestamp confirms the
recorder started but never closed the run (connection dropped mid-run).
### 4. Check latest state timestamp
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.73 \
"sudo mariadb homeassistant -e 'SELECT FROM_UNIXTIME(MAX(last_updated_ts)) AS latest_state FROM states;'"
```
Compare with current time. If `latest_state` is hours/days behind, recorder
stopped writing at that point.
### 5. Check `states` table size (information_schema — fast, no COUNT(*))
```bash
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.73 \
"sudo mariadb homeassistant -e 'SELECT table_name, table_rows, ROUND(data_length/1024/1024) AS data_mb FROM information_schema.tables WHERE table_schema=\"homeassistant\";'"
```
**⚠️ Do NOT run `SELECT COUNT(*) FROM states`** — with 62M+ rows and 6.6 GB,
this query hangs for 30+ seconds. Use `information_schema.table_rows`
instead (instant, approximate).
## Fix
```bash
# Restart HA core — recorder reconnects and resumes writing
ssh -i ~/.ssh/id_ed25519_proxmox -p 22222 root@10.0.30.10 "ha core restart"
```
Wait 2-3 minutes for HA to fully start (large DB schema check takes time).
## Verification
```bash
# Check latest state is now current
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.73 \
"sudo mariadb homeassistant -e 'SELECT FROM_UNIXTIME(MAX(last_updated_ts)) AS latest_state FROM states;'"
# Should show a timestamp within the last few seconds
# Check MaxScale now has ha_recorder session
ssh -i ~/.ssh/id_ed25519_proxmox debian@10.0.30.82 "maxctrl list sessions"
```
## Data Loss
The gap between the silent disconnect and the restart is permanently lost.
No state/event data was recorded during that period. InfluxDB (if configured
for HA) may still have the data since it's a separate integration with its
own connection.
## Session Reference
- **2026-07-08**: Recorder silently dropped after MaxScale `FLUSH HOSTS`
during Galera maintenance. Latest state was 2026-07-04 12:48 (4 days gap).
62.7M states, 6.6 GB on Galera. `ha core restart` fixed immediately.
`recorder_runs` run_id=106 open since 2026-07-01, `end=NULL`.
@@ -0,0 +1,212 @@
# Marstek Cascade Automation (Modbus)
HA automation `automation.marstek_modbus_steuerung` ("Marstek Kaskaden-Steuerung (Modbus)") controls the Marstek Venus battery as an overflow/reserve storage, capped at 2500W. It runs every 2 minutes and decides whether to charge, discharge, or stop the Marstek based on SMA battery state and home power balance.
## Automation Structure
Retrieved via REST API: `GET /api/config/automation/config/{numeric_id}` (numeric ID from automation entity `attributes.id` field — in this case `1773314167598`).
```yaml
alias: Marstek Kaskaden-Steuerung (Modbus)
description: "Marstek arbeitet als reiner Überlauf/Reserve-Speicher, gedeckelt auf max. 2500 W."
mode: single
triggers:
- minutes: "/2"
trigger: time_pattern
actions:
- variables:
production: "{{ states('sensor.sn_3007105790_metering_power_supplied') | float(0) }}"
power_home: "{{ states('sensor.sn_3007105790_metering_power_absorbed') | float(0) }}"
soc: "{{ states('sensor.sn_3007105790_battery_soc_total') | float(0) }}"
battery_charge: "{{ states('sensor.sn_3007105790_battery_power_charge_total') | float(0) }}"
discharge: "{{ states('sensor.sn_3007105790_battery_power_discharge_total') | float(0) }}"
marstek_current_power: "{{ states('number.marstek_venus_modbus_ladeleistung_einstellen') | float(0) }}"
marstek_current_discharge: "{{ states('number.marstek_venus_modbus_entladeleistung_einstellen') | float(0) }}"
echter_ueberschuss: "{{ production - power_home + marstek_current_power - marstek_current_discharge }}"
echter_bedarf: "{{ power_home - production + marstek_current_discharge - marstek_current_power }}"
- choose:
# Option 1: Charge when SMA battery is full AND there's surplus
- conditions:
- "{{ (soc >= 80 or battery_charge >= 3900) and echter_ueberschuss > 50 }}"
sequence:
- action: number.set_value
target:
entity_id: number.marstek_venus_modbus_ladeleistung_einstellen
data:
value: "{{ [ echter_ueberschuss | int, 2500 ] | min }}"
- action: select.select_option
target:
entity_id: select.marstek_venus_modbus_force_mode
data:
option: charge
# Option 2: Discharge when SMA battery is low AND there's demand
- conditions:
- "{{ (soc <= 25 or discharge >= 3900) and echter_bedarf > 50 }}"
sequence:
- action: number.set_value
target:
entity_id: number.marstek_venus_modbus_entladeleistung_einstellen
data:
value: "{{ [ echter_bedarf | int, 2500 ] | min }}"
- action: select.select_option
target:
entity_id: select.marstek_venus_modbus_force_mode
data:
option: discharge
# Default: stop
default:
- action: select.select_option
target:
entity_id: select.marstek_venus_modbus_force_mode
data:
option: stop
- action: number.set_value
target:
entity_id: number.marstek_venus_modbus_ladeleistung_einstellen
data:
value: 0
- action: number.set_value
target:
entity_id: number.marstek_venus_modbus_entladeleistung_einstellen
data:
value: 0
```
## Marstek Modbus Control Entities
| Entity | Type | Purpose |
|--------|------|---------|
| `number.marstek_venus_modbus_ladeleistung_einstellen` | number | Charge power setpoint (W) |
| `number.marstek_venus_modbus_entladeleistung_einstellen` | number | Discharge power setpoint (W) |
| `number.marstek_venus_modbus_maximale_ladeleistung` | number | Max charge power (hardware limit, 2500W) |
| `number.marstek_venus_modbus_maximale_entladeleistung` | number | Max discharge power (hardware limit, 2500W) |
| `select.marstek_venus_modbus_force_mode` | select | Force mode: `charge` / `discharge` / `stop` |
| `select.marstek_venus_modbus_modus` | select | User mode: `anti_feed` (prevents grid export) |
| `switch.marstek_venus_modbus_rs485_steuermodus` | switch | RS485 control mode (must be ON for Modbus control) |
Measured values (read-only):
| Entity | Purpose |
|--------|---------|
| `sensor.marstek_venus_modbus_ac_leistung` | Actual AC output power (W) |
| `sensor.marstek_venus_modbus_batterieleistung` | Battery power (W, negative = discharging) |
| `sensor.marstek_venus_modbus_batterie_ladezustand` | SoC (%) |
| `sensor.marstek_venus_modbus_entladeleistung` | Actual discharge power (W) |
| `sensor.marstek_venus_modbus_ladeleistung` | Actual charge power (W) |
## The Feedback-Loop Bug (Root Cause of ~200W Grid Import)
### The Problem
The `echter_bedarf` formula uses **setpoint** values (`number.*_einstellen`) instead of **measured** values:
```yaml
echter_bedarf: "{{ power_home - production + marstek_current_discharge - marstek_current_power }}"
```
Where:
- `marstek_current_discharge` = `states('number.marstek_venus_modbus_entladeleistung_einstellen')` — the **setpoint**, not actual discharge
- `marstek_current_power` = `states('number.marstek_venus_modbus_ladeleistung_einstellen')` — the **charge setpoint**, not actual charge
### Why This Spirals
With `power_home=870W`, `production=0W` (nighttime), and starting setpoint=0:
| Cycle | Setpoint | echter_bedarf = 870 + setpoint | New Setpoint = min(bedarf, 2500) |
|-------|----------|--------------------------------|----------------------------------|
| 1 | 0 | 870 | 870 |
| 2 | 870 | 1740 | 1740 |
| 3 | 1740 | 2610 | 2500 (capped) |
The controller ramps to 2500W within 3 cycles (6 minutes). The Marstek's `anti_feed` mode then throttles actual output to ~650W (preventing grid export). The mismatch between the 2500W command and the ~650W actual output leaves ~200W on the grid.
### The Fix (Applied 2026-07-10)
The user requested consistency with the existing automation structure. Rather than introducing new variables or rewriting the formulas, the fix swaps the **underlying sensor entities** from setpoints to measured values, keeping all variable names and formula structures unchanged:
```yaml
# BROKEN (uses setpoints → feedback loop):
marstek_current_power: "{{ states('number.marstek_venus_modbus_ladeleistung_einstellen') | float(0) }}"
marstek_current_discharge: "{{ states('number.marstek_venus_modbus_entladeleistung_einstellen') | float(0) }}"
# FIXED (uses measured values → stable, same variable names, same formulas):
marstek_current_power: "{{ states('sensor.marstek_ladeleistung') | float(0) }}"
marstek_current_discharge: "{{ states('sensor.marstek_entladeleistung') | float(0) }}"
```
The `echter_ueberschuss` and `echter_bedarf` formulas were NOT changed — only the two variable definitions above. This is the minimal, consistent patch.
With the fix: `echter_bedarf = 870 - 0 + 650 - 0 = 1520` → but `marstek_current_discharge` is now the *measured* 650W, not the setpoint. Next cycle: the Marstek adjusts toward the new setpoint, `marstek_current_discharge` tracks the actual output, and `echter_bedarf` converges to the true residual (~220W). No positive feedback, no spiral to 2500W.
**How the fix was applied:** Retrieved the automation config via REST API (`GET /api/config/automation/config/{id}`), modified the two variable values in the JSON, and POSTed it back. The HA REST API accepts the full config object as the request body and returns `{"result":"ok"}`.
```javascript
// In browser_console (after HA login):
(async () => {
const conn = await window.hassConnection;
const token = conn.auth.data.access_token;
const resp = await fetch('/api/config/automation/config/1773314167598', {
headers: {'Authorization': 'Bearer ' + token}
});
const config = await resp.json();
// Swap setpoint sensors for measured sensors
const vars = config.actions[0].variables;
vars.marstek_current_power = "{{ states('sensor.marstek_ladeleistung') | float(0) }}";
vars.marstek_current_discharge = "{{ states('sensor.marstek_entladeleistung') | float(0) }}";
// POST back
const updateResp = await fetch('/api/config/automation/config/1773314167598', {
method: 'POST',
headers: {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
return await updateResp.text(); // {"result":"ok"}
})()
```
### General Lesson: Setpoint vs Measured in Control Automations
**Never use a setpoint (command/output) variable in a feedback formula that computes the next setpoint.** This creates positive feedback — the controller amplifies its own output every cycle. Always use the **measured process variable** (sensor reading) to compute the error/demand. This is classic control theory: the feedback path must measure the actual plant output, not the controller's commanded output.
Pattern to watch for in HA automations:
```yaml
# DANGER: variable reads from number.*_einstellen (setpoint) → feedback loop
marstek_current_X: "{{ states('number.marstek_venus_modbus_X_einstellen') | float(0) }}"
# SAFE: variable reads from sensor.* (measured) → stable control
marstek_current_X: "{{ states('sensor.marstek_venus_modbus_X') | float(0) }}"
```
## Interaction with Marstek `anti_feed` Mode
The Marstek's user mode is set to `anti_feed` (`select.marstek_venus_modbus_modus`), which prevents exporting power to the grid. When the automation commands 2500W discharge but home only needs 870W, the Marstek internally throttles to avoid feeding 1630W back to the grid. This is correct behavior by the Marstek — the bug is in the automation commanding too much, not in the Marstek refusing to deliver.
## How to Retrieve HA Automation YAML
```javascript
// In browser_console (after HA login):
(async () => {
const conn = await window.hassConnection;
const token = conn.auth.data.access_token;
// Get numeric ID from entity attributes
const stateResp = await fetch('/api/states/automation.ENTITY_ID', {
headers: {'Authorization': 'Bearer ' + token}
});
const state = await stateResp.json();
const numericId = state.attributes.id;
// Fetch full automation config
const configResp = await fetch(`/api/config/automation/config/${numericId}`, {
headers: {'Authorization': 'Bearer ' + token}
});
const config = await configResp.json();
return JSON.stringify(config);
})()
```
Or via REST API directly (no browser needed if token available):
```bash
# Step 1: Get numeric ID
NUMERIC_ID=$(curl -s "$HA_URL/api/states/automation.ENTITY_ID" \
-H "Authorization: Bearer $HA_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin)['attributes']['id'])")
# Step 2: Get config
curl -s "$HA_URL/api/config/automation/config/$NUMERIC_ID" \
-H "Authorization: Bearer $HA_TOKEN" | python3 -m json.tool
```
@@ -0,0 +1,212 @@
# Midea AC Integration Debugging
## Integration Info
- **Repository**: https://github.com/mill1000/midea-ac-py
- **Integration version**: 2026.4.0
- **Required library**: `msmart-ng==2026.4.1`
- **Config flow file**: `/mnt/data/supervisor/homeassistant/custom_components/midea_ac/config_flow.py`
- **Manifest**: `/mnt/data/supervisor/homeassistant/custom_components/midea_ac/manifest.json`
## Cloud Credentials Structure
In `config_flow.py`, line ~71:
```python
_CLOUD_CREDENTIALS = {
"DE": ("midea_eu@mailinator.com", "das_ist_passwort1"),
"KR": ("midea_sea@mailinator.com", "password_for_sea1")
}
```
These shared credentials are used for the `discover` config flow step. When they expire, the flow aborts with `cloud_connection_failed`.
## Config Flow Steps
1. **Initiate**: `POST /api/config/config_entries/flow` with `{"handler":"midea_ac"}`
2. **Menu**: Choose `discover` or `manual`
3. **Discover**: Submit `{"host":"10.0.40.54","country_code":"DE"}` — uses cloud credentials to get token/k1
4. **Manual**: Submit `{"id":"DEVICE_ID","host":"IP","port":6444,"device_type":"AC","token":"...","k1":"..."}` — skips cloud, needs pre-obtained token/k1
## Library Comparison
### midealocal (Python pip package)
- Import: `from midealocal.cloud import get_midea_cloud`
- Cloud names: "SmartHome", "NetHome Plus", "Midea Air", "美的美居", "Ariston Clima"
- Login flow: `get_midea_cloud(cloud_name=..., session=..., account=..., password=...)``await cloud.login()`
- List devices: `await cloud.list_home()``await cloud.list_appliances(home_id)`
- Get keys: `await cloud.get_cloud_keys(int(device_id))` — calls `/v1/iot/secure/getToken`
- **Issue observed**: getToken returns `{"code":"3004","msg":"value is illegal."}` — empty result
### msmart-ng (used by HA integration)
- Import: `from msmart.cloud import SmartHomeCloud, NetHomePlusCloud`
- `SmartHomeCloud` has DE shared credentials: `midea_eu@mailinator.com` / `das_ist_passwort1`
- `SmartHomeCloud` also accepts **personal user credentials** — login succeeds, but `getToken` still returns 3004
- `NetHomePlusCloud` is used by `Discover.discover_single()` — hardcoded in `_get_cloud()`
- `Security.udpid(data)` = `sha256(data).digest()`, XOR first 16 bytes with second 16 bytes → `.hex()` (32 chars)
- `Discover.connect()` tries both `"little"` and `"big"` endians: `device_id.to_bytes(6, endian)`
- Cloud login internally calls `/v1/user/login/id/get``/mj/user/login`
- CLI: `python3 -m msmart.cli -d -a "EMAIL" -p 'PASS' -i "IP"`
- **Issue observed**: getToken returns `{"code":"3004","msg":"value is illegal"}` for ALL udpid variants
### ⚠️ Package Conflict: midealocal vs msmart-ng
Both packages claim the `msmart` Python namespace. Installing `midealocal` pulls in old `msmart==0.2.5` which overwrites `msmart-ng`'s modules. The `security.py` from 0.2.5 tries to import `MSGTYPE_ENCRYPTED_REQUEST` from `msmart.const` which doesn't exist in msmart-ng's const.py.
**Fix:**
```bash
pip uninstall -y msmart-ng midealocal msmart --break-system-packages
rm -rf ~/.local/lib/python3.*/site-packages/msmart # Remove stale namespace
pip install --break-system-packages msmart-ng==2026.4.1
```
**Rule: Use `midealocal` only in throwaway/debug contexts. Never install it alongside `msmart-ng` in any environment that the HA integration depends on.**
### msmart (older version, v0.2.5)
- Different API from msmart-ng
- `msmart.cloud.cloud` class with `login()`, `gettoken()`, `appliance_list()`
- Login fails with same "value is illegal" error
## Device Details (This Homelab)
- **Name**: OGKlimaanlage
- **Device ID**: 153931628805781
- **Type**: 172 (AC)
- **IP**: 10.0.40.54 (VLAN 40)
- **SN**: 000000P0000000Q1102C8D344AAE0000
- **SN8**: 00000Q11
- **Model**: 00000Q11
- **Model number**: 524
- **Manufacturer code**: 0000
- **Protocol**: V3
- **Port**: 6444 (TCP, **OPEN** but requires auth handshake — unauthenticated connections time out) / 6445 (UDP, responds to discovery broadcast)
- **Cloud account**: dominik@familie-schoen.com (MSmartHome app)
- **Cloud**: SmartHome (not NetHome Plus)
## Network Requirements
- Device is on VLAN 40 (10.0.40.x)
- HA needs direct L2 access — added third NIC (net2, VLAN 40, tag=40) to VM 106
- Interface in HAOS: `enp0s20`, configured with `ha network update enp0s20 --ipv4-method auto`
- HA gets IP 10.0.40.61 via DHCP
- Device ARP is reachable (MAC 10:2c:8d34:4a:ae) — TCP 6444 is open but requires V3 authentication handshake. Unauthenticated TCP connections time out ("No response from host").
## Debugging Commands
```bash
# Check config entry state
TOKEN=$(cat /tmp/ha_token.txt)
curl -s -H "Authorization: Bearer $TOKEN" \
"http://10.0.30.10:8123/api/config/config_entries/entry" | \
python3 -c "import sys,json; [print(f'{e[\"title\"]}: {e[\"state\"]}') for e in json.load(sys.stdin) if e.get('domain')=='midea_ac']"
# Check HA logs for midea errors
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.20.10 \
'qm guest exec 106 -- sh -c "ha core log 2>&1 | grep -i midea | tail -20"'
# Local discovery via midealocal
python3 -m midealocal.cli discover \
--username "EMAIL" --password 'PASS' \
--cloud-name "SmartHome" --host "10.0.40.54" --get_sn
# Cloud API debug (midealocal)
python3 << 'PYEOF'
import asyncio, aiohttp, json
from midealocal.cloud import get_midea_cloud
async def debug():
async with aiohttp.ClientSession() as session:
cloud = get_midea_cloud(
cloud_name="SmartHome", session=session,
account="EMAIL", password="PASS"
)
await cloud.login()
homes = await cloud.list_home()
for hid in homes:
apps = await cloud.list_appliances(hid)
for aid, info in (apps or {}).items():
print(f"Device {aid}: {json.dumps(info)}")
keys = await cloud.get_cloud_keys(int(aid))
print(f" Keys: {json.dumps(keys)}")
asyncio.run(debug())
PYEOF
```
## Session Findings (2026-06-27 Deep Debug)
### Cloud Login Results
| Cloud | Endpoint | Login | getToken | Notes |
|-------|----------|-------|----------|-------|
| SmartHome (midealocal, shared creds) | mp-prod.appsmb.com | ✅ | ❌ 3004 | `list_appliances` works, returns device with `btToken` + `btMac` |
| SmartHome (msmart-ng, shared DE creds) | mp-prod.appsmb.com | ✅ | ❌ 3004 | Same API, same failure |
| SmartHome (msmart-ng, **personal creds**) | mp-prod.appsmb.com | ✅ | ❌ 3004 | Login OK with user account, getToken STILL fails |
| SmartHome (midealocal, **personal creds**) | mp-prod.appsmb.com | ✅ | ❌ 3004 | Same — confirms device-side issue, not credentials |
| NetHome Plus (midealocal) | mapp.appsmb.com | ❌ 3101 | N/A | "password error" — account doesn't exist on this cloud |
| NetHome Plus (msmart-ng) | mapp.appsmb.com | ⏳ rate-limited | N/A | Too many failed attempts from earlier msmart-ng discovery tries |
| Midea Air (midealocal) | mapp.appsmb.com | ❌ | N/A | Login failed |
**Conclusion**: getToken fails regardless of credentials or library. The device has no LAN token in the cloud despite being connected via WLAN and remotely controllable through the MSmartHome app. Initial BLE pairing likely explains the absence of a LAN token — re-pairing via WLAN setup should provision one.
### getToken Failure — All udpid Methods Tested
**msmart-ng `Security.udpid(data)`**: `sha256(data).digest()`, XOR first 16 bytes with second 16 bytes → `.hex()` (32 hex chars)
```python
from msmart.lan import Security
for endian in ["little", "big"]:
data = device_id.to_bytes(6, endian)
udpid = Security.udpid(data).hex() # → "ad4d091264847e0f..." / "b7f074ba7343aa11..."
```
**midealocal `CloudSecurity.get_udp_id()`**: 3 methods (different byte slicing/reversing)
```python
# All return {"code":"3004","msg":"value is illegal."}
methods = {
0: "REVERSED_BIG", # bytes(reversed(id.to_bytes(8, "big")))
1: "BIG", # id.to_bytes(6, "big")
2: "LITTLE", # id.to_bytes(6, "little"),
}
```
Also tested: 8-byte big/little endian, btMac bytes (`102c8d344aae`), SN string bytes — ALL return 3004.
### Device Cloud Info (from list_appliances)
```json
{
"id": "153931628805781",
"name": "OGKlimaanlage",
"type": "0xAC",
"modelNumber": "524",
"sn": "000000P0000000Q1102C8D344AAE0000",
"sn8": "00000Q11",
"onlineStatus": "1",
"connectType": "1",
"btMac": "102c8d344aaf",
"btToken": "6000001415208481",
"registerTime": "Tue Jun 17 09:40:37 UTC 2025"
}
```
**Key insight**: `btToken` and `btMac` present but NO `mac` field (null). `connectType: "1"` indicates initial BLE pairing. However, user confirmed the device IS connected via WLAN and remotely controllable through the MSmartHome app — so the device has WiFi, but the cloud has no LAN token provisioned (likely because initial pairing was via BLE, not WLAN setup).
### Default Keys Test
`midealocal.DEFAULT_KEYS` (method 99) returns a token/key pair, but authentication via `msmart.lan.LAN.authenticate()` fails with "Error packet received" — these are generic placeholders, not device-specific keys.
### Resolution
1. **Re-pair the device via WLAN setup in the MSmartHome app** (not Bluetooth) — this should generate a LAN token in the Midea cloud
2. After re-pairing, re-run `cloud.get_cloud_keys(153931628805781)` — if it returns keys, use the manual config flow
3. Alternatively: use the MSmartHome app's "Gerät Startposition" (shown as `UnasBiFNEq...`) — this may be the udpid, but it was truncated in the app UI
4. Once token+k1 obtained: use manual config flow:
```
POST /api/config/config_entries/flow {"handler":"midea_ac"}
→ choose "manual"
→ {"id":"153931628805781","host":"10.0.40.54","port":6444,"device_type":"AC","token":"TOKEN_HEX","k1":"KEY_HEX"}
```
### Pending
- ✅ Midea credentials stored in 1Password (Vault "Hermes", item "Midea MSmartHome", ID jzlkqibo44e6nz6gtvcqf7dg6q)
- Verify re-pairing via WLAN setup generates a LAN token
@@ -0,0 +1,192 @@
# Xenia Coffee Machine REST API Reference
Device: Xenia Coffee (ESP32-based controller)
IP: 10.0.50.128 (VLAN 50/IoT)
Firmware: ESP FW 3.9
Serial: 313253795029
Web UI: `http://10.0.50.128/` (Bootstrap, German)
## API Discovery
The JS bundle reveals all endpoints:
```bash
curl -s --compressed http://10.0.50.128/js/index_combined.js | grep -oP '/api/v2/[a-z_]+'
```
## Endpoints
### GET /api/v2/overview
Returns live machine telemetry. Poll this for sensor data.
```json
{
"MA_EXTRACTIONS": 1877,
"MA_OPERATING_HOURS": 39140,
"MA_STATUS": 0, // 0=Off, 1=On
"MA_CLOCK": 1318439,
"MA_CUR_PWR": 0, // Watts (current draw)
"MA_MAX_PWR": 10, // Max ampere setting
"MA_ENERGY_TOTAL_KWH": 13.25,
"BG_SENS_TEMP_A": 32.7, // Brew group temp °C
"BG_LEVEL_PW_CONTROL": 0,
"PU_SENS_PRESS": 0, // Pump pressure
"PU_LEVEL_PW_CONTROL": 0,
"PU_SET_LEVEL_PW_CONTROL": 0,
"SB_SENS_PRESS": 0, // Steam boiler pressure
"BB_SENS_TEMP_A": 32.2, // Brew boiler temp °C
"BB_LEVEL_PW_CONTROL": 0,
"SB_STATUS": 1, // Steam boiler status
"MA_LAST_EXTRACTION_ML": ""
}
```
### GET /api/v2/machine
Machine configuration and identity.
```json
{
"MA_TYPE": 0,
"MA_FIX_WATER_SUPPLY": 0,
"MA_HEATUP_FLUSH_EN": 0,
"MA_SET_TIMER_ECO_MA": 0,
"MA_SET_TIMER_POWERDOWN": 0,
"MA_MAX_AMPERE": 10,
"MA_ENERGY_TOTAL_KWH": 13.25,
"FW_VERSION_MAJOR": 0,
"FW_VERSION_MINOR": 0,
"ESP_FW_MAJOR": 3,
"ESP_FW_MINOR": 9,
"MA_SN": "313253795029"
}
```
### POST /api/v2/machine/control
Power control endpoint. POST with JSON body:
- `{"action": 5}` — Turn ON
- `{"action": 3}` — Turn OFF
- `{"action": 4}` — ECO mode
HA `rest_command.xenia_machine_control` calls this endpoint.
### Other Endpoints
- `GET /api/v2/toggle_sb` — Toggle steam boiler
- `GET /api/v2/inc_dec` — Increment/decrement parameter
- `GET /api/v2/inc_dec_bb` — Adjust brew boiler
- `GET /api/v2/inc_dec_sb` — Adjust steam boiler
- `GET /api/v2/language` — Language setting
- `GET /api/v2/overview_single` — Single-value overview (used for set temperatures)
- `GET /api/v2/system/serial` — Serial number
- `GET /api/v2/system/reset_serial` — Reset serial
- `GET /get_all_params` — All parameters (returns empty in observed session)
- `GET /api/brew_start` — Start a brew shot
## Web UI Pages
- `/` — Overview (dashboard)
- `/machine` — Machine settings
- `/brewgroup` — Brew group settings
- `/brewboiler` — Brew boiler settings
- `/scripts` — Script management
- `/paddle` — Paddle configuration
- `/pid` — PID tuning
- `/steamboiler` — Steam boiler settings
## HA Integration (confirmed 2026-07-10)
### Configuration Architecture
The Xenia integration uses a hybrid approach:
1. **Modern `rest:` integration** (in `configuration.yaml`) — polls `/api/v2/overview` and `/api/v2/overview_single` for raw machine data
2. **Template entities** (in `configuration.yaml` under `template:`) — derive human-readable sensors from the REST sensor attributes
3. **`rest_command:`** — sends control commands to the machine
4. **Manual device registry entry** — groups all entities under one "Xenia" device (see "Device Creation" below)
### Working Entities
| Entity | Type | Source |
|--------|------|--------|
| `sensor.xenia_machine_data` | REST sensor | `/api/v2/overview` (modern `rest:` syntax) |
| `sensor.xenia_set_temperatures` | REST sensor | `/api/v2/overview_single` (modern `rest:` syntax) |
| `sensor.xenia_status` | Template sensor | Derived from `MA_STATUS` (On/Off) |
| `sensor.brew_group_temperature` | Template sensor | `BG_SENS_TEMP_A` |
| `sensor.brew_boiler_temperature` | Template sensor | `BB_SENS_TEMP_A` |
| `sensor.brew_group_set_temperature` | Template sensor | Set temp |
| `switch.xenia_power_2` | Template switch | Calls `rest_command.xenia_machine_control` |
| `rest_command.xenia_machine_control` | REST command | `POST /api/v2/machine/control` |
### Broken/Orphaned Entities
| Entity | Issue |
|--------|-------|
| `sensor.xenia_current_power_2` | `unavailable` — derived from broken integration sensor |
| `sensor.xenia_total_energy_2` | `unavailable` — derived from broken integration sensor |
| `switch.xenia_power` | `unavailable`, `restored: true` — old entity, superseded by `switch.xenia_power_2` |
### Device Creation (confirmed 2026-07-10)
**Challenge:** `device_info` is NOT supported in `rest:` or `template:` YAML integrations. HA rejects it with "Invalid config for 'rest': 'device_info' is an invalid option".
**Solution:** Manually create the device in `.storage/core.device_registry` and link entities via `.storage/core.entity_registry`, using SSH access to the HA container (see "HAOS Advanced SSH Addon Access" in SKILL.md).
**Result:** Device "Xenia" (ID: `42aef3df-3746-41bd-9292-e987a64d42d0`) created with:
- Manufacturer: Xenia Coffee
- Model: Xenia Espresso
- Serial: 313253795029
- SW Version: 3.9
- Configuration URL: http://10.0.50.128
5 entities linked to the device: `switch.xenia_power`, `switch.xenia_power_2`, `sensor.xenia_status`, `sensor.xenia_current_power_2`, `sensor.xenia_total_energy_2`.
**Key learnings:**
1. `device_info` does NOT work in `rest:` or `template:` YAML — confirmed by HA error messages
2. Manual `.storage/` device entries ARE persisted across restarts IF all required fields are present (see SKILL.md pitfall for complete field list)
3. Entity-to-device links in `.storage/core.entity_registry` also survive restarts
4. The `rest:` modern syntax (vs legacy `sensor: - platform: rest`) was adopted during this process — cleaner structure but still no `device_info` support
### Power Switch Verification (confirmed 2026-06-29)
`switch.xenia_power_2` turn_on/turn_off both work via HA API. Verified end-to-end:
1. `POST /api/services/switch/turn_on {"entity_id":"switch.xenia_power_2"}` → switch state = `on`
2. Device API confirms: `MA_STATUS: 1`, `MA_CUR_PWR: 8.3W`, temp rising (39.4°C → 48.4°C)
3. `POST /api/services/switch/turn_off {"entity_id":"switch.xenia_power_2"}` → switch state = `off`
4. Device API confirms: `MA_STATUS: 0`
Note: `rest_command.xenia_machine_control` also toggles power but returns empty response (not an error). The `switch.xenia_power_2` REST switch is the cleaner interface.
### Alexa Exposure
`switch.xenia_power_2` is NOT exposed to Alexa via HA Cloud. The old `switch.xenia_power` (now dead) was likely the previously exposed entity. Fix: add `switch.xenia_power_2` to HA Cloud Alexa exposure list, or use `switch_as_x` to create a light entity. Cannot fix via API alone — Alexa exposure list is managed in HA UI.
## Configuration YAML (Modern rest: Syntax)
The `configuration.yaml` uses modern `rest:` integration syntax (converted from legacy `sensor: - platform: rest`):
```yaml
rest:
- resource: http://10.0.50.128/api/v2/overview
method: GET
timeout: 30
scan_interval: 1
sensor:
- name: "Xenia Machine Data"
unique_id: xenia_machine_data
value_template: >-
{% if value_json is defined %}OK{% else %}Error{% endif %}
json_attributes:
- MA_STATUS
- BG_SENS_TEMP_A
- BB_SENS_TEMP_A
- PU_SENS_PRESS
- SB_SENS_PRESS
- MA_CUR_PWR
- MA_ENERGY_TOTAL_KWH
- resource: http://10.0.50.128/api/v2/overview_single
method: GET
timeout: 30
scan_interval: 60
sensor:
- name: "Xenia Set Temperatures"
unique_id: xenia_set_temperatures
value_template: >-
{% if value_json is defined %}OK{% else %}Error{% endif %}
json_attributes:
- BG_SET_TEMP
```
Template entities derive from these REST sensors and a template switch controls power via `rest_command`.
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""MQTT Broker Scanner & Auth Tester.
Scans VLANs for MQTT brokers (port 1883/8883) and tests connectivity
by sending a raw MQTT v3.1.1 CONNECT packet and reading the CONNACK.
Usage:
python3 mqtt-broker-scan.py # Scan known homelab VLANs
python3 mqtt-broker-scan.py 10.0.30.0/24 # Scan specific subnet
python3 mqtt-broker-scan.py 10.0.30.10 1883 # Test specific broker
"""
import socket
import struct
import sys
import concurrent.futures
KNOWN_SUBNETS = [
"10.0.30.", # Server/HA VLAN
"10.0.40.", # Guest/IoT VLAN
"10.0.50.", # IoT VLAN
]
def check_port(ip, port, timeout=0.3):
"""Quick TCP connect check."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
result = s.connect_ex((ip, port))
s.close()
return result == 0
except Exception:
return False
def encode_remaining_length(length):
"""MQTT variable-length encoding for remaining length field."""
bytes_rl = []
while True:
byte = length % 128
length = length // 128
if length > 0:
byte += 128
bytes_rl.append(byte)
if length == 0:
break
return bytes(bytes_rl)
def mqtt_connect_test(host, port=1883, client_id="hermes-scan", timeout=5):
"""Send minimal MQTT v3.1.1 CONNECT, read CONNACK, return status string."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect((host, port))
# Variable header: Protocol Name + Level + Flags + Keep Alive
var_header = (
b'\x00\x04MQTT' # Protocol name "MQTT"
b'\x04' # Protocol level (3.1.1)
b'\x02' # Flags: Clean Session
b'\x00\x3c' # Keep alive: 60s
)
# Payload: Client ID (length-prefixed)
cid = client_id.encode('utf-8')
payload = struct.pack('>H', len(cid)) + cid
# Fixed header: CONNECT (0x10) + remaining length
remaining = len(var_header) + len(payload)
fixed_header = b'\x10' + encode_remaining_length(remaining)
s.send(fixed_header + var_header + payload)
# Read CONNACK: 4 bytes (0x20, 0x02, session_present, return_code)
connack = s.recv(4)
s.close()
if len(connack) >= 4 and connack[0] == 0x20:
rc = connack[3]
codes = {
0: 'Accepted',
1: 'Bad protocol version',
2: 'Client ID rejected',
3: 'Server unavailable',
4: 'Bad username/password',
5: 'Not authorized',
}
return codes.get(rc, f'Unknown ({rc})')
else:
return f'Unexpected response: {connack.hex()}'
except Exception as e:
return f'Connection failed: {e}'
def scan_subnet(prefix, ports=(1883, 8883)):
"""Scan a /24 subnet for open MQTT ports. Returns list of (ip, port) tuples."""
found = []
def scan(ip):
hits = []
for p in ports:
if check_port(ip, p):
hits.append(p)
return (ip, hits)
with concurrent.futures.ThreadPoolExecutor(max_workers=128) as ex:
futures = {
ex.submit(scan, f"{prefix}{i}"): i
for i in range(1, 255)
}
for f in concurrent.futures.as_completed(futures):
ip, hits = f.result()
if hits:
found.append((ip, hits))
return sorted(found)
def main():
args = sys.argv[1:]
if len(args) >= 2:
# Test specific broker: mqtt-broker-scan.py <ip> <port>
host, port = args[0], int(args[1])
print(f"Testing {host}:{port}...")
result = mqtt_connect_test(host, port)
print(f" Result: {result}")
return
if len(args) == 1 and '/' in args[0]:
# Scan specific subnet: mqtt-broker-scan.py 10.0.30.0/24
prefix = args[0].rsplit('.', 1)[0] + '.'
print(f"Scanning {prefix}0/24 for MQTT brokers...")
results = scan_subnet(prefix)
else:
# Scan known homelab subnets
print("Scanning known homelab VLANs for MQTT brokers...")
results = []
for prefix in KNOWN_SUBNETS:
print(f" Scanning {prefix}0/24...", end=' ', flush=True)
found = scan_subnet(prefix)
if found:
print(f"found {len(found)}")
results.extend(found)
else:
print("none")
print(f"\n=== Results: {len(results)} broker(s) found ===")
for ip, ports in results:
for port in ports:
proto = "MQTT" if port == 1883 else f"MQTT-TLS({port})"
auth = mqtt_connect_test(ip, port)
print(f" {proto} {ip}:{port} -> {auth}")
if __name__ == '__main__':
main()
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Verify HA recorder/history/logbook status via REST API
# Usage: ./verify-ha-recorder.sh
# Requires: HA_URL and HA_TOKEN in ~/.hermes/.env or environment
set -euo pipefail
# Load env
if [ -f ~/.hermes/.env ]; then
source ~/.hermes/.env
fi
: "${HA_URL:?Set HA_URL in ~/.hermes/.env}"
: "${HA_TOKEN:?Set HA_TOKEN in ~/.hermes/.env}"
echo "=== HA State ==="
STATE=$(curl -sf "$HA_URL/api/config" -H "Authorization: Bearer $HA_TOKEN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'State: {d[\"state\"]}, Version: {d[\"version\"]}')")
echo "$STATE"
echo ""
echo "=== Service Domains ==="
curl -sf "$HA_URL/api/services" -H "Authorization: Bearer $HA_TOKEN" | python3 -c "
import sys,json
data=json.load(sys.stdin)
services=set(s['domain'] for s in data)
checks=['recorder','history','logbook']
allok=True
for d in checks:
ok = d in services
if not ok: allok=False
print(f' {d}: {\"✓\" if ok else \"✗\"}')
print(f'\nAll critical services: {\"PASS ✓\" if allok else \"FAIL ✗\"}')
"
echo ""
echo "=== History API Test ==="
RESULT=$(curl -sf -w "\n%{http_code}" "$HA_URL/api/history/period?filter_entity_id=sun.sun" -H "Authorization: Bearer $HA_TOKEN" 2>&1)
HTTP_CODE=$(echo "$RESULT" | tail -1)
BODY=$(echo "$RESULT" | head -n -1)
echo " HTTP: $HTTP_CODE"
echo "$BODY" | python3 -c "
import sys,json
try:
data=json.load(sys.stdin)
if isinstance(data, list):
total=sum(len(g) for g in data)
print(f' History entries returned: {total}')
print(' Result: PASS ✓' if total > 0 else ' Result: EMPTY (may be normal)')
except Exception as e:
print(f' Parse error: {e}')
" 2>&1
echo ""
echo "=== Logbook API Test ==="
RESULT2=$(curl -sf -w "\n%{http_code}" "$HA_URL/api/logbook/2026-06-27T00:00:00?entity=sun.sun" -H "Authorization: Bearer $HA_TOKEN" 2>&1)
HTTP_CODE2=$(echo "$RESULT2" | tail -1)
echo " HTTP: $HTTP_CODE2"
echo " Result: $([ "$HTTP_CODE2" = "200" ] && echo 'PASS ✓' || echo 'FAIL ✗')"
@@ -0,0 +1,78 @@
# EVCC config with custom charger bridged through HA REST API
#
# Use this when EVCC can't directly connect to a charger (TLS cert issues,
# API changes, device on unreachable VLAN) but the native HA integration works.
#
# Replace __HA_URL__ with http://10.0.30.10:8123 (internal HA IP)
# Replace __HA_TOKEN__ with a long-lived access token from HA
#
# Installation: Stop HA VM (qm stop 106), mount RBD disk, write to
# /mnt/haos/supervisor/homeassistant/evcc.yaml, unmount, start VM.
# Then restart EVCC add-on via Supervisor API.
network:
schema: http
host: 0.0.0.0
port: 7070
log: info
interval: 30s
chargers:
- name: wallbox
type: custom
status:
source: http
uri: __HA_URL__/api/states/sensor.ev_charger_schon_status_ladevorgang
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: 'if .state == "active_mode" then "C" elif .state == "sleep_mode" then "B" elif .state == "not_connected" then "A" else "F" end'
enabled:
source: http
uri: __HA_URL__/api/states/switch.ev_charger_schon_manuelle_ladefreigabe
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state == "on"'
enable:
source: http
uri: __HA_URL__/api/services/switch/{{ if .enable }}turn_on{{ else }}turn_off{{ end }}
method: POST
headers:
Authorization: "Bearer __HA_TOKEN__"
Content-Type: application/json
body: '{"entity_id": "switch.ev_charger_schon_manuelle_ladefreigabe"}'
maxcurrent:
source: http
uri: __HA_URL__/api/services/number/set_value
method: POST
headers:
Authorization: "Bearer __HA_TOKEN__"
Content-Type: application/json
body: '{"entity_id": "number.ev_charger_schon_ac_strom_begrenzung", "value": {{.maxcurrent}}}'
power:
source: http
uri: __HA_URL__/api/states/sensor.ev_charger_schon_leistung_ladestation
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state | tonumber'
energy:
source: http
uri: __HA_URL__/api/states/sensor.ev_charger_schon_zahlerstand_ladestation
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state | tonumber'
scale: 0.001
loadpoints:
- title: Wallbox
charger: wallbox
mode: pv
phases: 0
mincurrent: 6
maxcurrent: 16
enable:
threshold: 0
delay: 1m
disable:
threshold: 500
delay: 3m
@@ -0,0 +1,199 @@
# Full EVCC config — Schön Consulting homelab
#
# Includes: grid meter, 3 PV inverters, 2 batteries, custom charger via HA,
# site config, Tibber tariff.
#
# Use this as a reference when modifying the EVCC config. Replace __HA_TOKEN__
# with a long-lived HA access token.
#
# Installation: Stop HA VM (qm stop 106), mount RBD disk, write to
# /mnt/haos/supervisor/homeassistant/evcc.yaml, unmount, start VM.
# Then restart EVCC add-on via Supervisor API.
#
# WARNING: EVCC add-on overwrites this file on restart. Stop VM, edit, start VM.
network:
schema: http
host: 0.0.0.0
port: 7070
log: info
interval: 30s
# ─── METERS (grid, PV, batteries) ───────────────────────────
meters:
# Grid meter — SMA Energy Meter via Modbus
- name: grid
type: custom
power:
source: modbus
id: 3
uri: 10.0.50.201:502
register:
address: 30865
type: input
decode: int32nan
energy:
source: modbus
id: 3
uri: 10.0.50.201:502
register:
address: 30529
type: input
decode: uint32nan
scale: 0.001
# PV inverter 1 — SMA via Modbus
- name: pv1
type: template
template: sma-inverter-modbus
usage: pv
modbus: tcpip
host: 10.0.50.201
port: 502
# PV inverter 2 — SMA via Modbus
- name: pv2
type: template
template: sma-inverter-modbus
usage: pv
modbus: tcpip
host: 10.0.50.203
port: 502
# SMA Sunny Boy Storage battery via Modbus
- name: battery
type: template
template: sma-sbs-modbus
usage: battery
modbus: tcpip
host: 10.0.50.202
port: 502
maxchargepower: 4200
watchdog: 60s
# Marstek Venus E battery — bridged through HA REST API
# Direct Modbus (10.0.50.113:502) is closed from EVCC's network.
# HA's Marstek Modbus integration reads it fine, so bridge via HA entities.
# NOTE: Do NOT add 'usage: battery' — custom type doesn't accept it.
# The 'soc:' field implicitly marks this as a battery.
- name: marstek
type: custom
capacity: 5
maxchargepower: 2500
maxdischargepower: 2500
power:
source: http
uri: http://10.0.30.10:8123/api/states/sensor.marstek_venus_modbus_ac_leistung
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state | tonumber'
soc:
source: http
uri: http://10.0.30.10:8123/api/states/sensor.marstek_venus_modbus_batterie_ladezustand
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state | tonumber'
energy:
source: http
uri: http://10.0.30.10:8123/api/states/sensor.marstek_venus_modbus_gesamte_entladeenergie
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state | tonumber'
# Hoymiles micro-inverter via OpenDTU
- name: hoymiles
type: template
template: hoymiles-opendtu
usage: pv
host: 10.0.40.55
# ─── CHARGER (custom, bridged through HA REST API) ──────────
# SMA EV Charger (10.0.50.205) — direct connection fails (TLS cert
# without IP-SANs, SMA API returns 400). Native HA SMA integration
# works via different network path. Bridge through HA entities.
chargers:
- name: wallbox
type: custom
status:
source: http
uri: http://10.0.30.10:8123/api/states/sensor.ev_charger_schon_status_ladevorgang
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: 'if .state == "active_mode" then "C" elif .state == "sleep_mode" then "B" elif .state == "not_connected" then "A" else "F" end'
enabled:
source: http
uri: http://10.0.30.10:8123/api/states/switch.ev_charger_schon_manuelle_ladefreigabe
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state == "on"'
enable:
source: http
uri: http://10.0.30.10:8123/api/services/switch/{{ if .enable }}turn_on{{ else }}turn_off{{ end }}
method: POST
headers:
Authorization: "Bearer __HA_TOKEN__"
Content-Type: application/json
body: '{"entity_id": "switch.ev_charger_schon_manuelle_ladefreigabe"}'
maxcurrent:
source: http
uri: http://10.0.30.10:8123/api/services/number/set_value
method: POST
headers:
Authorization: "Bearer __HA_TOKEN__"
Content-Type: application/json
body: '{"entity_id": "number.ev_charger_schon_ac_strom_begrenzung", "value": {{.maxcurrent}}}'
power:
source: http
uri: http://10.0.30.10:8123/api/states/sensor.ev_charger_schon_leistung_ladestation
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state | tonumber'
energy:
source: http
uri: http://10.0.30.10:8123/api/states/sensor.ev_charger_schon_zahlerstand_ladestation
headers:
Authorization: "Bearer __HA_TOKEN__"
jq: '.state | tonumber'
scale: 0.001
# ─── LOADPOINT ──────────────────────────────────────────────
loadpoints:
- title: Wallbox
charger: wallbox
mode: pv
phases: 0
mincurrent: 6
maxcurrent: 16
enable:
threshold: 0
delay: 1m
disable:
threshold: 500
delay: 3m
# ─── SITE ───────────────────────────────────────────────────
site:
title: Zuhause
meters:
grid: grid
pv:
- pv1
- pv2
- hoymiles
battery:
- battery
- marstek
residualPower: 100
# ─── TARIFFS ────────────────────────────────────────────────
tariffs:
currency: EUR
grid:
type: tibber
token: "__TIBBER_TOKEN__"