Compare commits
3491
Commits
0ed1687503
..
main
+1
-1
@@ -1 +1 @@
|
||||
2026.7.2
|
||||
2026.8.3
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
+31
-13
@@ -1,14 +1,32 @@
|
||||
# Home Assistant Core Exclusions
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.log
|
||||
.storage/
|
||||
# Only track specific file types
|
||||
# Ignore everything by default
|
||||
*
|
||||
|
||||
# === Home Assistant Version Control Managed (do not edit below) ===
|
||||
# Tracked file types
|
||||
!*.yaml
|
||||
!**/.??*.yaml
|
||||
!*.yml
|
||||
!**/.??*.yml
|
||||
!*.md
|
||||
!**/.??*.md
|
||||
!*.json
|
||||
!**/.??*.json
|
||||
!*.js
|
||||
!**/.??*.js
|
||||
|
||||
# Track specific .storage configuration files
|
||||
!.storage/lovelace
|
||||
!.storage/lovelace_dashboards
|
||||
!.storage/lovelace_resources
|
||||
!.storage/lovelace.*
|
||||
# === End Home Assistant Version Control Managed ===
|
||||
|
||||
# Allow directory traversal
|
||||
!*/
|
||||
|
||||
# Re-ignore macOS metadata files (even if they match allowed extensions)
|
||||
._*
|
||||
|
||||
# Excluded files
|
||||
secrets.yaml
|
||||
*icon*.png
|
||||
*state.json
|
||||
|
||||
|
||||
# Custom integrations and private tokens
|
||||
.google_nest_prompt_info.json
|
||||
nest_protect_cookies.json
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"enable_auto_backup": true,
|
||||
"auto_backup_throttle_minutes": 0,
|
||||
"auto_backup_retain_per_entity": 100,
|
||||
"auto_backup_dir": "",
|
||||
"auto_backup_calendar_lookahead_days": 7,
|
||||
"enable_snapshot_delete": false,
|
||||
"snapshot_delete_min_age_days": 7
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"enable_tool_search": false,
|
||||
"tool_search_max_results": 10
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1 +1 @@
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784644362.8406641}
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.8.3", "start_ts": 1787776418.1303492}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["opencode-gemini-auth@latest"],
|
||||
"model": "opencode/big-pickle",
|
||||
"provider": {
|
||||
"google": {
|
||||
"options": {
|
||||
"projectId": "gen-lang-client-0842463959",
|
||||
"model": "google/gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Stats Dashboard Session - 2026-07-27
|
||||
|
||||
## Dashboard: Stats (url_path: dashboard-temp)
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Added Memory Usage Gauge
|
||||
- Added `sensor.system_monitor_memory_usage` gauge card next to CPU gauge
|
||||
- Entity: `sensor.system_monitor_memory_usage`
|
||||
- Severity: green(0), yellow(50), red(80)
|
||||
- Both gauges set to `grid_options: {columns: 6}` for side-by-side layout
|
||||
|
||||
### 2. Combined CPU & Memory History Chart
|
||||
- Updated existing CPU history-graph card to include both sensors
|
||||
- Renamed to "System Resources"
|
||||
- Entities:
|
||||
- `sensor.home_assistant_cpu_usage` (CPU Usage)
|
||||
- `sensor.system_monitor_memory_usage` (Memory Usage)
|
||||
- Hours to show: 24
|
||||
|
||||
## Current System Status
|
||||
- Memory usage: 61.0% (yellow zone, normal for 8GB system)
|
||||
- Memory sensor reports percentage (%)
|
||||
|
||||
## File Locations
|
||||
- Dashboard config: storage (url_path: dashboard-temp)
|
||||
- Section index: views[0].sections[1] (System Health section)
|
||||
- Gauge cards: cards[0] (CPU), cards[1] (Memory)
|
||||
- History chart: cards[2] (System Resources)
|
||||
|
||||
## Notes
|
||||
- `sensor.system_monitor_memory_usage` is also listed in the System Health entities card (views[0].sections[1].cards[3])
|
||||
@@ -0,0 +1,45 @@
|
||||
# Nest Snapshot Session — July 29, 2026
|
||||
|
||||
## Goal
|
||||
Capture still images from Nest cameras (Google Nest SDM API) for AI (Ollama) analysis.
|
||||
|
||||
## What Was Built
|
||||
Custom integration `nest_snapshot` at `/homeassistant/custom_components/nest_snapshot/`:
|
||||
- `manifest.json`, `services.yaml`, `__init__.py`
|
||||
- Uses WebRTC (`aiortc`) to grab one video frame → saves as JPEG
|
||||
- Test automation `automation.test_nest_snapshot_capture`
|
||||
|
||||
## Bug Fixes Applied
|
||||
|
||||
### Fix 1: `Nest integration data not available`
|
||||
- **Root cause:** HA 2026.7 stores Nest runtime data in `config_entry.runtime_data` (NestData dataclass), not in `hass.data["nest"]`. Camera entity `unique_id` has `-camera` suffix, but `device_manager.devices` is keyed by raw Google device name.
|
||||
- **Changes:** Line 69: `config_entry.runtime_data` instead of `hass.data.get(...)`. Lines 78-81: `-camera` suffix stripping before device_manager lookup.
|
||||
- **Status:** ✅ Fixed
|
||||
|
||||
### Fix 2: `aiortc` module not installed
|
||||
- **Root cause:** `aiortc` and `Pillow` not in manifest.json requirements.
|
||||
- **Changes:** Added `"requirements": ["aiortc", "Pillow"]` to manifest.json.
|
||||
- **Status:** ✅ Fixed
|
||||
|
||||
### Fix 3: SDP format — "Offer must contain audio, video and application m lines"
|
||||
- **Root cause:** Only added video transceiver; Google needs audio + video + application in order.
|
||||
- **Changes:** Added `pc.addTransceiver("audio", ...)`, `pc.addTransceiver("video", ...)`, `pc.createDataChannel(...)`.
|
||||
- **Status:** ✅ Fixed
|
||||
|
||||
### Fix 4 (current): `invalid literal for int() with base 10: 'udp'`
|
||||
- **Root cause:** `aiortc` ICE candidate parser expects `candidate:ID <int:component> udp ...` but Google Nest API sends `candidate:ID udp ...` (missing component number).
|
||||
- **Attempt 1:** Sanitize via `_sanitize_sdp()` checking `parts[1].lower() == "udp"` — didn't match actual format.
|
||||
- **Attempt 2:** Changed to `not parts[1].isdigit()` — still failing. Possibly the line format is `a=candidate:udp ...` where `parts[0].split(':')[1]` is `udp` and `parts[1]` IS a digit.
|
||||
- **Current state:** Added debug logging of raw SDP from Google. Need restart to see logs.
|
||||
- **Status:** 🔴 In progress — add logging to see actual SDP format
|
||||
|
||||
## File States
|
||||
- `__init__.py` — has `_sanitize_sdp()` with `not parts[1].isdigit()` check + debug logs
|
||||
- `manifest.json` — has `["aiortc", "Pillow"]` requirements
|
||||
- `services.yaml` — unchanged, defines `capture` service
|
||||
- `AGENTS.local.md` — restart rule added (must ask each time)
|
||||
|
||||
## Pending Changes (committed but not tested after latest edit)
|
||||
- `_sanitize_sdp()` now uses `not parts[1].isdigit()` (broader match)
|
||||
- Debug logging added for raw + sanitized SDP
|
||||
- Need restart → test → check system log for SDP content
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.alarm_section",
|
||||
"data": {
|
||||
"config": {
|
||||
"views": [
|
||||
{
|
||||
"type": "sections",
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading_style": "title",
|
||||
"heading": "Sensors"
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "binary_sensor.entryway"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.basement"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.office_occupancy"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.downstairs_bathroom_occupancy"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.night_light_occupancy"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.front_door"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.patio_door"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.office_door_contact"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.garage_door_contact"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.bedroom_window"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.seraphine_window"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.bedroom_window"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.side_window_1"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.side_window_2"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.swimming_pool_window"
|
||||
},
|
||||
{
|
||||
"entity": "input_boolean.blink_backyard_camera_motion"
|
||||
},
|
||||
{
|
||||
"entity": "input_boolean.blink_front_camera_motion"
|
||||
},
|
||||
{
|
||||
"entity": "input_boolean.blink_shed_camera_motion"
|
||||
},
|
||||
{
|
||||
"entity": "input_boolean.blink_inside_shed_camera_motion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading_style": "title",
|
||||
"heading": "Cameras"
|
||||
},
|
||||
{
|
||||
"square": false,
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "camera.front_door_front_door",
|
||||
"show_entity_picture": true
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "camera.entryway_entryway_camera",
|
||||
"show_entity_picture": true,
|
||||
"vertical": false,
|
||||
"features_position": "bottom"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "camera.living_room_living_room_camera",
|
||||
"show_entity_picture": true,
|
||||
"vertical": false,
|
||||
"features_position": "bottom"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "camera.hallway_hallway_camera",
|
||||
"show_entity_picture": true,
|
||||
"vertical": false,
|
||||
"features_position": "bottom"
|
||||
}
|
||||
],
|
||||
"columns": 2
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "binary_sensor.night_light_occupancy"
|
||||
},
|
||||
{
|
||||
"entity": "input_boolean.blink_shed_camera_motion"
|
||||
},
|
||||
{
|
||||
"entity": "input_boolean.blink_inside_shed_camera_motion"
|
||||
},
|
||||
{
|
||||
"entity": "input_boolean.blink_front_camera_motion"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.office_occupancy"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.downstairs_bathroom_occupancy"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.basement"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.entryway"
|
||||
},
|
||||
{
|
||||
"entity": "binary_sensor.garage_door_contact"
|
||||
}
|
||||
],
|
||||
"title": "Motion Senors",
|
||||
"hours_to_show": 6,
|
||||
"logarithmic_scale": false,
|
||||
"expand_legend": false
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.night_light_illuminance"
|
||||
},
|
||||
{
|
||||
"entity": "light.night_light"
|
||||
}
|
||||
],
|
||||
"hours_to_show": 24
|
||||
},
|
||||
{
|
||||
"type": "custom:mushroom-entity-card",
|
||||
"entity": "input_boolean.office_motion_override",
|
||||
"name": "Office Motion Override",
|
||||
"icon": "mdi:home-account",
|
||||
"icon_color": "green",
|
||||
"secondary_info": "state",
|
||||
"tap_action": {
|
||||
"action": "toggle"
|
||||
},
|
||||
"hold_action": {
|
||||
"action": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "alarm_control_panel.blainville_alarm",
|
||||
"features": [
|
||||
{
|
||||
"type": "alarm-modes"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.dashboard_music",
|
||||
"data": {
|
||||
"config": {
|
||||
"views": [
|
||||
{
|
||||
"type": "sections",
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Amazon Devices",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Alexa Music Router",
|
||||
"show_header_toggle": false,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "input_select.alexa_playlist",
|
||||
"name": "Select Playlist"
|
||||
},
|
||||
{
|
||||
"entity": "input_select.alexa_target_device",
|
||||
"name": "Target Speaker"
|
||||
},
|
||||
{
|
||||
"entity": "input_number.alexa_master_volume",
|
||||
"name": "Speaker Volume"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"columns": 2,
|
||||
"square": false,
|
||||
"cards": [
|
||||
{
|
||||
"type": "button",
|
||||
"name": "Play Music",
|
||||
"icon": "mdi:play",
|
||||
"tap_action": {
|
||||
"action": "call-service",
|
||||
"service": "script.dynamic_alexa_spotify_player"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"name": "Stop Music",
|
||||
"icon": "mdi:stop",
|
||||
"tap_action": {
|
||||
"action": "call-service",
|
||||
"service": "script.stop_alexa_music"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Office Speaker",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "custom:mass-player-card",
|
||||
"entities": [
|
||||
"media_player.office_speaker_2",
|
||||
"media_player.francos_macbook_pro"
|
||||
],
|
||||
"panel": true,
|
||||
"sync_player_across_dashboard": true,
|
||||
"media_browser": {
|
||||
"recommendations": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:my-music-library-card",
|
||||
"entity": "media_player.office_speaker_2",
|
||||
"grid_options": {
|
||||
"columns": 12,
|
||||
"rows": "auto"
|
||||
}
|
||||
}
|
||||
],
|
||||
"column_span": 1
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "iframe",
|
||||
"url": "/d5369777_music_assistant",
|
||||
"aspect_ratio": "75%",
|
||||
"grid_options": {
|
||||
"columns": "full",
|
||||
"rows": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"column_span": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.dashboard_temp",
|
||||
"data": {
|
||||
"config": {
|
||||
"views": [
|
||||
{
|
||||
"type": "sections",
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:uptime-card",
|
||||
"entity": "sensor.nas_status",
|
||||
"name": "NAS Availability",
|
||||
"icon": "mdi:nas",
|
||||
"ok": [
|
||||
"up",
|
||||
"Up",
|
||||
"online"
|
||||
],
|
||||
"ko": [
|
||||
"down",
|
||||
"Down",
|
||||
"offline"
|
||||
],
|
||||
"alias": {
|
||||
"ok": "Up",
|
||||
"ko": "Down"
|
||||
},
|
||||
"show_state": true,
|
||||
"bar": {
|
||||
"amount": 30
|
||||
},
|
||||
"title_adaptive_color": true,
|
||||
"status_adaptive_color": false,
|
||||
"icon_adaptive_color": true
|
||||
},
|
||||
{
|
||||
"type": "custom:uptime-card",
|
||||
"entity": "sensor.homeassistant_status",
|
||||
"name": "NAS Availability",
|
||||
"icon": "mdi:nas",
|
||||
"ok": [
|
||||
"up",
|
||||
"Up",
|
||||
"online"
|
||||
],
|
||||
"ko": [
|
||||
"down",
|
||||
"Down",
|
||||
"offline"
|
||||
],
|
||||
"alias": {
|
||||
"ok": "Up",
|
||||
"ko": "Down"
|
||||
},
|
||||
"show_state": true,
|
||||
"bar": {
|
||||
"amount": 30
|
||||
},
|
||||
"title_adaptive_color": true,
|
||||
"status_adaptive_color": false,
|
||||
"icon_adaptive_color": true
|
||||
},
|
||||
{
|
||||
"type": "custom:uptime-card",
|
||||
"entity": "sensor.ollama_status",
|
||||
"name": "NAS Availability",
|
||||
"icon": "mdi:nas",
|
||||
"ok": [
|
||||
"up",
|
||||
"Up",
|
||||
"online"
|
||||
],
|
||||
"ko": [
|
||||
"down",
|
||||
"Down",
|
||||
"offline"
|
||||
],
|
||||
"alias": {
|
||||
"ok": "Up",
|
||||
"ko": "Down"
|
||||
},
|
||||
"show_state": true,
|
||||
"bar": {
|
||||
"amount": 30
|
||||
},
|
||||
"title_adaptive_color": true,
|
||||
"status_adaptive_color": false,
|
||||
"icon_adaptive_color": true
|
||||
},
|
||||
{
|
||||
"type": "custom:uptime-card",
|
||||
"entity": "sensor.next_cloud_status",
|
||||
"name": "NAS Availability",
|
||||
"icon": "mdi:nas",
|
||||
"ok": [
|
||||
"up",
|
||||
"Up",
|
||||
"online"
|
||||
],
|
||||
"ko": [
|
||||
"down",
|
||||
"Down",
|
||||
"offline"
|
||||
],
|
||||
"alias": {
|
||||
"ok": "Up",
|
||||
"ko": "Down"
|
||||
},
|
||||
"show_state": true,
|
||||
"bar": {
|
||||
"amount": 30
|
||||
},
|
||||
"title_adaptive_color": true,
|
||||
"status_adaptive_color": false,
|
||||
"icon_adaptive_color": true
|
||||
},
|
||||
{
|
||||
"type": "custom:uptime-card",
|
||||
"entity": "sensor.octoprint_status",
|
||||
"name": "NAS Availability",
|
||||
"icon": "mdi:printer-3d",
|
||||
"ok": [
|
||||
"up",
|
||||
"Up",
|
||||
"online"
|
||||
],
|
||||
"ko": [
|
||||
"down",
|
||||
"Down",
|
||||
"offline"
|
||||
],
|
||||
"alias": {
|
||||
"ok": "Up",
|
||||
"ko": "Down"
|
||||
},
|
||||
"show_state": true,
|
||||
"bar": {
|
||||
"amount": 30
|
||||
},
|
||||
"title_adaptive_color": true,
|
||||
"status_adaptive_color": false,
|
||||
"icon_adaptive_color": true
|
||||
},
|
||||
{
|
||||
"type": "sensor",
|
||||
"entity": "sensor.nas_response_time",
|
||||
"name": "NAS Latency",
|
||||
"graph": "line",
|
||||
"hours_to_show": 24
|
||||
},
|
||||
{
|
||||
"type": "sensor",
|
||||
"entity": "sensor.homeassistant_response_time",
|
||||
"name": "HA Latency",
|
||||
"graph": "line",
|
||||
"hours_to_show": 24
|
||||
},
|
||||
{
|
||||
"graph": "line",
|
||||
"type": "sensor",
|
||||
"entity": "sensor.octoprint_response_time",
|
||||
"hours_to_show": 24,
|
||||
"detail": 1
|
||||
},
|
||||
{
|
||||
"graph": "line",
|
||||
"type": "sensor",
|
||||
"entity": "sensor.ollama_response_time",
|
||||
"hours_to_show": 24,
|
||||
"detail": 1
|
||||
},
|
||||
{
|
||||
"graph": "line",
|
||||
"type": "sensor",
|
||||
"entity": "sensor.next_cloud_response_time",
|
||||
"hours_to_show": 24,
|
||||
"detail": 1
|
||||
},
|
||||
{
|
||||
"graph": "line",
|
||||
"type": "sensor",
|
||||
"entity": "sensor.homeassistant_certificate_expiry",
|
||||
"hours_to_show": 24,
|
||||
"detail": 1
|
||||
},
|
||||
{
|
||||
"graph": "line",
|
||||
"type": "sensor",
|
||||
"entity": "sensor.octoprint_certificate_expiry",
|
||||
"hours_to_show": 24,
|
||||
"detail": 1
|
||||
},
|
||||
{
|
||||
"graph": "line",
|
||||
"type": "sensor",
|
||||
"entity": "sensor.next_cloud_certificate_expiry",
|
||||
"hours_to_show": 24,
|
||||
"detail": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.home_assistant_cpu_usage",
|
||||
"name": "CPU Usage",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 50,
|
||||
"red": 80
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.system_monitor_memory_usage",
|
||||
"name": "Memory Usage",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 50,
|
||||
"red": 80
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.home_assistant_cpu_usage",
|
||||
"name": "CPU Usage"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_memory_usage",
|
||||
"name": "Memory Usage"
|
||||
}
|
||||
],
|
||||
"title": "System Resources",
|
||||
"hours_to_show": 24,
|
||||
"show_names": true,
|
||||
"logarithmic_scale": false,
|
||||
"expand_legend": false
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.system_monitor_load_1_min",
|
||||
"name": "Load 1m"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_load_5_min",
|
||||
"name": "Load 5m"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_load_15_min",
|
||||
"name": "Load 15m"
|
||||
}
|
||||
],
|
||||
"title": "System Load",
|
||||
"hours_to_show": 24
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "System Health",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "System Health",
|
||||
"show_header_toggle": false,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.system_monitor_memory_usage",
|
||||
"name": "Memory Usage"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_memory_free",
|
||||
"name": "Memory Free"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_disk_use",
|
||||
"name": "Disk Used"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_disk_free",
|
||||
"name": "Disk Free"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_load_1_min",
|
||||
"name": "Load 1 Minute"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_load_5_min",
|
||||
"name": "Load 5 Minute"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_load_15_min",
|
||||
"name": "Load 15 Minute"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.system_monitor_uptime"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.home_assistant_containers_active"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.home_assistant_enp1s0_rx"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.home_assistant_enp1s0_tx"
|
||||
}
|
||||
],
|
||||
"title": "Network Traffic",
|
||||
"hours_to_show": 24
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Network Counters",
|
||||
"entities": [
|
||||
"sensor.system_monitor_packets_in_enp1s0",
|
||||
"sensor.system_monitor_packets_out_enp1s0"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"icon": "mdi:printer-3d-nozzle",
|
||||
"heading": "OctoPrint",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.octoprint_cpu_usage",
|
||||
"name": "CPU Usage",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 50,
|
||||
"red": 80
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.octoprint_memory_usage",
|
||||
"name": "Memory Usage",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 50,
|
||||
"red": 80
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.octoprint_wlx90de80348ee7_rx",
|
||||
"name": "WiFi RX"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_wlx90de80348ee7_tx",
|
||||
"name": "WiFi TX"
|
||||
}
|
||||
],
|
||||
"hours_to_show": 24
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "HVAC Stats",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"square": false,
|
||||
"columns": 1,
|
||||
"cards": [
|
||||
{
|
||||
"type": "statistics-graph",
|
||||
"title": "Daily Breakdown (Last 7 Days)",
|
||||
"chart_type": "bar",
|
||||
"period": "day",
|
||||
"days_to_show": 7,
|
||||
"stat_types": [
|
||||
"change"
|
||||
],
|
||||
"entities": [
|
||||
"sensor.nest_cooling_time_stats",
|
||||
"sensor.nest_heating_time_stats"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "statistics-graph",
|
||||
"title": "Monthly Trend (Last 6 Months)",
|
||||
"chart_type": "line",
|
||||
"period": "month",
|
||||
"days_to_show": 180,
|
||||
"stat_types": [
|
||||
"max",
|
||||
"change"
|
||||
],
|
||||
"entities": [
|
||||
"sensor.nest_cooling_monthly",
|
||||
"sensor.nest_heating_monthly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "statistics-graph",
|
||||
"title": "Yearly Comparison",
|
||||
"chart_type": "bar",
|
||||
"period": "month",
|
||||
"days_to_show": 365,
|
||||
"stat_types": [
|
||||
"max",
|
||||
"change"
|
||||
],
|
||||
"entities": [
|
||||
"sensor.nest_cooling_yearly",
|
||||
"sensor.nest_heating_yearly"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "statistics-graph",
|
||||
"chart_type": "bar",
|
||||
"period": "day",
|
||||
"days_to_show": 7,
|
||||
"stat_types": [
|
||||
"change"
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.nest_cooling_weekly",
|
||||
"name": "Cooling"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.nest_heating_weekly",
|
||||
"name": "Heating"
|
||||
}
|
||||
],
|
||||
"title": "Weekly HVAC Usage Breakdown"
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Dishwasher"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "binary_sensor.150633095332665_door",
|
||||
"icon": "mdi:door-open",
|
||||
"vertical": false,
|
||||
"features_position": "bottom"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.150633095332665_status"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.150633095332665_progress"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "binary_sensor.150633095332665_rinse_aid"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.150633095332665_temperature"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.150633095332665_mode"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.150633095332665_bright"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.150633095332665_error_code"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.150633095332665_softwater"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.150633095332665_time_remaining"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "binary_sensor.150633095332665_salt"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "counter.dishwasher_wash_cycles"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "counter.dishwasher_total_wash_cycles"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Network Stats",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Network Status",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_connected_devices",
|
||||
"name": "Connected Clients",
|
||||
"icon": "mdi:devices"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wan_download_speed",
|
||||
"name": "Download Speed",
|
||||
"icon": "mdi:download-network"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wan_upload_speed",
|
||||
"name": "Upload Speed",
|
||||
"icon": "mdi:upload-network"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wired_download_speed",
|
||||
"name": "Wired D/L"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wired_upload_speed",
|
||||
"name": "Wired U/L"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "statistics-graph",
|
||||
"entities": [
|
||||
"sensor.gt_ax11000_wan_download_speed",
|
||||
"sensor.gt_ax11000_wan_upload_speed"
|
||||
],
|
||||
"days_to_show": 1,
|
||||
"period": "hour",
|
||||
"chart_type": "line",
|
||||
"stat_types": [
|
||||
"mean"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Power Usage",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "custom:treemap-card",
|
||||
"entities": [
|
||||
"sensor.*power"
|
||||
],
|
||||
"exclude": [
|
||||
"*meter00*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "New section"
|
||||
},
|
||||
{
|
||||
"type": "custom:simple-thermostat",
|
||||
"entity": "climate.living_room_living_room"
|
||||
},
|
||||
{
|
||||
"type": "custom:simple-thermostat",
|
||||
"entity": "climate.vt_basement"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Blink Backyard Tree"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "camera.blink_backyard_tree",
|
||||
"show_entity_picture": true
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "binary_sensor.blink_backyard_tree_motion"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.blink_backyard_tree_temperature"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"max_columns": 4,
|
||||
"title": "Main",
|
||||
"cards": []
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"path": "",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:climate-scheduler-card"
|
||||
}
|
||||
],
|
||||
"title": "Climate"
|
||||
},
|
||||
{
|
||||
"type": "sections",
|
||||
"max_columns": 4,
|
||||
"title": "TaskMate",
|
||||
"path": "test",
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:taskmate-overview-card",
|
||||
"entity": "sensor.taskmate_overview",
|
||||
"title": "TaskMate",
|
||||
"child": "Seraphine Skyler"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Overview",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "custom:taskmate-calendar-card",
|
||||
"entity": "sensor.taskmate_overview",
|
||||
"title": "Task Calendar"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cards": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.ender5_printer",
|
||||
"data": {
|
||||
"config": {
|
||||
"views": [
|
||||
{
|
||||
"title": "Ender 5",
|
||||
"path": "printer",
|
||||
"icon": "mdi:printer-3d-nozzle",
|
||||
"type": "sections",
|
||||
"max_columns": 3,
|
||||
"subview": false,
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"icon": "mdi:printer",
|
||||
"heading_style": "title",
|
||||
"heading": "Ender 5",
|
||||
"badges": [
|
||||
{
|
||||
"type": "entity",
|
||||
"show_state": true,
|
||||
"show_icon": false,
|
||||
"entity": "sensor.octoprint_print_status"
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"icon": "mdi:pause",
|
||||
"text": "Pause",
|
||||
"tap_action": {
|
||||
"action": "perform-action",
|
||||
"perform_action": "switch.turn_on",
|
||||
"target": {
|
||||
"entity_id": "switch.octoprint_pause_print"
|
||||
}
|
||||
},
|
||||
"visibility": [
|
||||
{
|
||||
"condition": "state",
|
||||
"entity": "binary_sensor.octoprint_paused",
|
||||
"state": "off"
|
||||
},
|
||||
{
|
||||
"condition": "state",
|
||||
"entity": "binary_sensor.octoprint_printing",
|
||||
"state": "on"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"icon": "mdi:play",
|
||||
"text": "Resume",
|
||||
"tap_action": {
|
||||
"action": "perform-action",
|
||||
"perform_action": "switch.turn_off",
|
||||
"target": {
|
||||
"entity_id": "switch.octoprint_pause_print"
|
||||
}
|
||||
},
|
||||
"visibility": [
|
||||
{
|
||||
"condition": "state",
|
||||
"entity": "binary_sensor.octoprint_paused",
|
||||
"state": "on"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"icon": "mdi:stop",
|
||||
"text": "Cancel",
|
||||
"color": "#F44336",
|
||||
"tap_action": {
|
||||
"action": "perform-action",
|
||||
"perform_action": "button.press",
|
||||
"target": {
|
||||
"entity_id": "button.octoprint_cancel_print"
|
||||
},
|
||||
"confirmation": {
|
||||
"text": "Cancel the current print?"
|
||||
}
|
||||
},
|
||||
"visibility": [
|
||||
{
|
||||
"condition": "state",
|
||||
"entity": "binary_sensor.octoprint_printing",
|
||||
"state": "on"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"icon": "mdi:alert-octagon",
|
||||
"text": "Emergency stop",
|
||||
"color": "#F44336",
|
||||
"tap_action": {
|
||||
"action": "perform-action",
|
||||
"perform_action": "button.press",
|
||||
"target": {
|
||||
"entity_id": "button.octoprint_emergency_stop"
|
||||
},
|
||||
"confirmation": {
|
||||
"text": "Emergency stop the printer NOW?"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.octoprint_tool_0_temperature",
|
||||
"show_name": false,
|
||||
"show_state": false,
|
||||
"show_icon": false,
|
||||
"tap_action": {
|
||||
"action": "more-info"
|
||||
},
|
||||
"custom_fields": {
|
||||
"content": "[[[\n const sec = 'var(--secondary-text-color)';\n const pri = 'var(--primary-text-color)';\n const v = (Math.round(parseFloat(states['sensor.octoprint_tool_0_temperature']?.state) || 0)) + '\\u00B0C';\n const s = 'target: ' + (Math.round(parseFloat(states['sensor.octoprint_tool_0_target']?.state) || 0)) + '\\u00B0C';\n return `\n <div style=\"position:absolute; top:10px; left:12px; right:12px; bottom:10px; display:flex; flex-direction:column; align-items:flex-start; justify-content:flex-start; gap:3px;\">\n <div style=\"display:flex; align-items:center; gap:4px;\">\n <ha-icon icon=\"mdi:printer-3d-nozzle\" style=\"color:#4CAF50; --mdc-icon-size:12px;\"></ha-icon>\n <span style=\"font-size:10px; color:${sec}; text-transform:uppercase; letter-spacing:0.05em;\">Nozzle</span>\n </div>\n <span style=\"font-size:18px; font-weight:600; color:${pri}; line-height:1;\">${v}</span>\n <span style=\"font-size:10px; color:${sec};\">${s}</span>\n </div>\n `;\n]]]"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
},
|
||||
{
|
||||
"height": "74px"
|
||||
},
|
||||
{
|
||||
"position": "relative"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"border": "1px solid rgba(var(--rgb-primary-text-color), 0.08)"
|
||||
}
|
||||
],
|
||||
"custom_fields": {
|
||||
"content": [
|
||||
{
|
||||
"position": "absolute"
|
||||
},
|
||||
{
|
||||
"inset": "0"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.octoprint_bed_temperature",
|
||||
"show_name": false,
|
||||
"show_state": false,
|
||||
"show_icon": false,
|
||||
"tap_action": {
|
||||
"action": "more-info"
|
||||
},
|
||||
"custom_fields": {
|
||||
"content": "[[[\n const sec = 'var(--secondary-text-color)';\n const pri = 'var(--primary-text-color)';\n const v = (Math.round(parseFloat(states['sensor.octoprint_bed_temperature']?.state) || 0)) + '\\u00B0C';\n const s = 'target: ' + (Math.round(parseFloat(states['sensor.octoprint_bed_target']?.state) || 0)) + '\\u00B0C';\n return `\n <div style=\"position:absolute; top:10px; left:12px; right:12px; bottom:10px; display:flex; flex-direction:column; align-items:flex-start; justify-content:flex-start; gap:3px;\">\n <div style=\"display:flex; align-items:center; gap:4px;\">\n <ha-icon icon=\"mdi:radiator\" style=\"color:#4CAF50; --mdc-icon-size:12px;\"></ha-icon>\n <span style=\"font-size:10px; color:${sec}; text-transform:uppercase; letter-spacing:0.05em;\">Print bed</span>\n </div>\n <span style=\"font-size:18px; font-weight:600; color:${pri}; line-height:1;\">${v}</span>\n <span style=\"font-size:10px; color:${sec};\">${s}</span>\n </div>\n `;\n]]]"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
},
|
||||
{
|
||||
"height": "74px"
|
||||
},
|
||||
{
|
||||
"position": "relative"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"border": "1px solid rgba(var(--rgb-primary-text-color), 0.08)"
|
||||
}
|
||||
],
|
||||
"custom_fields": {
|
||||
"content": [
|
||||
{
|
||||
"position": "absolute"
|
||||
},
|
||||
{
|
||||
"inset": "0"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.octoprint_print_time_left",
|
||||
"show_name": false,
|
||||
"show_state": false,
|
||||
"show_icon": false,
|
||||
"tap_action": {
|
||||
"action": "more-info"
|
||||
},
|
||||
"custom_fields": {
|
||||
"content": "[[[\n const sec = 'var(--secondary-text-color)';\n const pri = 'var(--primary-text-color)';\n const t = parseFloat(states['sensor.octoprint_print_time_left']?.state) || 0;\n const h = Math.floor(t / 3600);\n const m = Math.round((t % 3600) / 60);\n let primary = '--';\n if (t > 0) {\n primary = h > 0 ? `${h}h ${m}m` : `${m}m`;\n }\n let secondary = '';\n const et = states['sensor.octoprint_approximate_completion_time']?.state;\n if (et && !['unknown','unavailable',''].includes(et)) {\n const d = new Date(et);\n secondary = `Done: ~${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;\n }\n return `\n <div style=\"position:absolute; top:10px; left:12px; right:12px; bottom:10px; display:flex; flex-direction:column; align-items:flex-start; justify-content:flex-start; gap:3px;\">\n <div style=\"display:flex; align-items:center; gap:4px;\">\n <ha-icon icon=\"mdi:clock-outline\" style=\"color:#4CAF50; --mdc-icon-size:12px;\"></ha-icon>\n <span style=\"font-size:10px; color:${sec}; text-transform:uppercase; letter-spacing:0.05em;\">Time left</span>\n </div>\n <span style=\"font-size:18px; font-weight:600; color:${pri}; line-height:1;\">${primary}</span>\n <span style=\"font-size:10px; color:${sec};\">${secondary}</span>\n </div>\n `;\n ]]]"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
},
|
||||
{
|
||||
"height": "74px"
|
||||
},
|
||||
{
|
||||
"position": "relative"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"border": "1px solid rgba(var(--rgb-primary-text-color), 0.08)"
|
||||
}
|
||||
],
|
||||
"custom_fields": {
|
||||
"content": [
|
||||
{
|
||||
"position": "absolute"
|
||||
},
|
||||
{
|
||||
"inset": "0"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.octoprint_current_z",
|
||||
"show_name": false,
|
||||
"show_state": false,
|
||||
"show_icon": false,
|
||||
"tap_action": {
|
||||
"action": "more-info"
|
||||
},
|
||||
"custom_fields": {
|
||||
"content": "[[[\n const sec = 'var(--secondary-text-color)';\n const pri = 'var(--primary-text-color)';\n const v = states['sensor.octoprint_current_z']?.state + ' mm';\n const s = '';\n return `\n <div style=\"position:absolute; top:10px; left:12px; right:12px; bottom:10px; display:flex; flex-direction:column; align-items:flex-start; justify-content:flex-start; gap:3px;\">\n <div style=\"display:flex; align-items:center; gap:4px;\">\n <ha-icon icon=\"mdi:axis-z-arrow\" style=\"color:#4CAF50; --mdc-icon-size:12px;\"></ha-icon>\n <span style=\"font-size:10px; color:${sec}; text-transform:uppercase; letter-spacing:0.05em;\">Height</span>\n </div>\n <span style=\"font-size:18px; font-weight:600; color:${pri}; line-height:1;\">${v}</span>\n <span style=\"font-size:10px; color:${sec};\">${s}</span>\n </div>\n `;\n]]]"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
},
|
||||
{
|
||||
"height": "74px"
|
||||
},
|
||||
{
|
||||
"position": "relative"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"border": "1px solid rgba(var(--rgb-primary-text-color), 0.08)"
|
||||
}
|
||||
],
|
||||
"custom_fields": {
|
||||
"content": [
|
||||
{
|
||||
"position": "absolute"
|
||||
},
|
||||
{
|
||||
"inset": "0"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.octoprint_print_progress",
|
||||
"show_name": false,
|
||||
"show_state": false,
|
||||
"show_icon": false,
|
||||
"tap_action": {
|
||||
"action": "none"
|
||||
},
|
||||
"custom_fields": {
|
||||
"content": "[[[\n const pct = Math.round(parseFloat(states['sensor.octoprint_print_progress']?.state) || 0);\n let file = states['sensor.octoprint_print_file']?.state || '--';\n if (['unknown','unavailable'].includes(file)) file = 'No file';\n file = file.replace(/\\.gcode$/i, '');\n return `\n <div style=\"position:absolute; top:14px; left:16px; right:16px; bottom:14px; display:flex; flex-direction:column; align-items:flex-start; gap:6px;\">\n <div style=\"display:flex; align-items:center; gap:5px;\">\n <ha-icon icon=\"mdi:chart-line\" style=\"color:#4CAF50; --mdc-icon-size:14px;\"></ha-icon>\n <span style=\"font-size:11px; color:var(--secondary-text-color); text-transform:uppercase; letter-spacing:0.05em;\">Print progress</span>\n </div>\n <div style=\"display:flex; align-items:baseline; gap:10px;\">\n <span style=\"font-size:32px; font-weight:600; color:var(--primary-text-color); line-height:1;\">${pct}%</span>\n <span style=\"font-size:13px; color:var(--secondary-text-color); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%;\">${file}</span>\n </div>\n <div style=\"width:100%; height:6px; background:rgba(var(--rgb-primary-text-color), 0.1); border-radius:3px; overflow:hidden; margin-top:auto;\">\n <div style=\"width:${pct}%; height:100%; background:#4CAF50; border-radius:3px;\"></div>\n </div>\n </div>\n `;\n ]]]"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
},
|
||||
{
|
||||
"height": "74px"
|
||||
},
|
||||
{
|
||||
"position": "relative"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"height": "100px"
|
||||
}
|
||||
],
|
||||
"custom_fields": {
|
||||
"content": [
|
||||
{
|
||||
"position": "absolute"
|
||||
},
|
||||
{
|
||||
"inset": "0"
|
||||
},
|
||||
{
|
||||
"padding": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:webrtc-camera",
|
||||
"url": "http://endercamera.duckdns.org/?action=stream",
|
||||
"ui": true
|
||||
},
|
||||
{
|
||||
"type": "custom:webrtc-camera",
|
||||
"url": "http://endercamera2.duckdns.org/?action=stream",
|
||||
"ui": true
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:apexcharts-card",
|
||||
"header": {
|
||||
"show": false
|
||||
},
|
||||
"graph_span": "1h",
|
||||
"series": [
|
||||
{
|
||||
"entity": "sensor.octoprint_tool_0_temperature",
|
||||
"name": "Nozzle",
|
||||
"color": "#E53935",
|
||||
"stroke_width": 2,
|
||||
"extend_to": "now"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_bed_temperature",
|
||||
"name": "Bed",
|
||||
"color": "#FB8C00",
|
||||
"stroke_width": 2,
|
||||
"extend_to": "now"
|
||||
}
|
||||
],
|
||||
"apex_config": {
|
||||
"chart": {
|
||||
"height": 130,
|
||||
"toolbar": {
|
||||
"show": false
|
||||
}
|
||||
},
|
||||
"stroke": {
|
||||
"curve": "smooth"
|
||||
},
|
||||
"grid": {
|
||||
"borderColor": "rgba(var(--rgb-primary-text-color), 0.05)"
|
||||
},
|
||||
"yaxis": {
|
||||
"min": 0,
|
||||
"max": 280,
|
||||
"labels": {
|
||||
"style": {
|
||||
"fontSize": "9px"
|
||||
},
|
||||
"maxWidth": 28
|
||||
}
|
||||
},
|
||||
"xaxis": {
|
||||
"labels": {
|
||||
"style": {
|
||||
"fontSize": "9px"
|
||||
}
|
||||
},
|
||||
"datetimeFormatter": {
|
||||
"hour": "HH:mm",
|
||||
"minute": "HH:mm"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"x": {
|
||||
"format": "HH:mm"
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"show": true,
|
||||
"fontSize": "10px",
|
||||
"horizontalAlign": "left",
|
||||
"offsetX": 28
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "horizontal-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "switch.octoprint_connect_to_printer",
|
||||
"show_state": false,
|
||||
"tap_action": {
|
||||
"action": "toggle"
|
||||
},
|
||||
"name": "Connect",
|
||||
"icon": "mdi:lan-connect",
|
||||
"state": [
|
||||
{
|
||||
"value": "off",
|
||||
"name": "Disconnect",
|
||||
"icon": "mdi:lan-disconnect"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"height": "91px"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"border": "1px solid rgba(var(--rgb-primary-text-color), 0.08)"
|
||||
},
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-size": "11px",
|
||||
"font-weight": "500"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"--mdc-icon-size": "26px"
|
||||
},
|
||||
{
|
||||
"margin-top": "6px"
|
||||
},
|
||||
{
|
||||
"color": "[[[ return states['switch.octoprint_connect_to_printer']?.state === 'on' ? '#4CAF50' : 'var(--secondary-text-color)'; ]]]"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "switch.octoprint_camera_snapshot",
|
||||
"show_state": false,
|
||||
"tap_action": {
|
||||
"action": "toggle"
|
||||
},
|
||||
"name": "Snapshots",
|
||||
"icon": "mdi:camera",
|
||||
"state": [
|
||||
{
|
||||
"value": "off",
|
||||
"name": "Snapshots",
|
||||
"icon": "mdi:camera-off"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"height": "91px"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"border": "1px solid rgba(var(--rgb-primary-text-color), 0.08)"
|
||||
},
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-size": "11px",
|
||||
"font-weight": "500"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"--mdc-icon-size": "26px"
|
||||
},
|
||||
{
|
||||
"margin-top": "6px"
|
||||
},
|
||||
{
|
||||
"color": "[[[ return states['switch.octoprint_camera_snapshot']?.state === 'on' ? '#4CAF50' : 'var(--secondary-text-color)'; ]]]"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"name": "GCode viewer",
|
||||
"icon": "mdi:cube-outline",
|
||||
"show_state": false,
|
||||
"tap_action": {
|
||||
"action": "url",
|
||||
"url_path": "https://ender5pro.duckdns.org/#gcode"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"height": "91px"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"border": "1px solid rgba(var(--rgb-primary-text-color), 0.08)"
|
||||
},
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-size": "11px",
|
||||
"font-weight": "500"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"--mdc-icon-size": "26px"
|
||||
},
|
||||
{
|
||||
"margin-top": "6px"
|
||||
},
|
||||
{
|
||||
"color": "#4CAF50"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"name": "Pretty GCode",
|
||||
"icon": "mdi:cube-scan",
|
||||
"show_state": false,
|
||||
"tap_action": {
|
||||
"action": "url",
|
||||
"url_path": "https://ender5pro.duckdns.org/#tab_plugin_prettygcode"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"height": "91px"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"border": "1px solid rgba(var(--rgb-primary-text-color), 0.08)"
|
||||
},
|
||||
{
|
||||
"background": "var(--ha-card-background, var(--card-background-color))"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-size": "11px",
|
||||
"font-weight": "500"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"--mdc-icon-size": "26px"
|
||||
},
|
||||
{
|
||||
"margin-top": "6px"
|
||||
},
|
||||
{
|
||||
"color": "#4CAF50"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.in_meeting",
|
||||
"data": {
|
||||
"config": {
|
||||
"wallpanel": {
|
||||
"enabled": true,
|
||||
"hide_toolbar": false,
|
||||
"hide_sidebar": true,
|
||||
"fullscreen": false,
|
||||
"idle_time": 0
|
||||
},
|
||||
"views": [
|
||||
{
|
||||
"title": "Main",
|
||||
"path": "main",
|
||||
"type": "panel",
|
||||
"cards": [
|
||||
{
|
||||
"type": "grid",
|
||||
"columns": 2,
|
||||
"square": false,
|
||||
"cards": [
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"show_name": true,
|
||||
"show_icon": true,
|
||||
"type": "button",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"name": "OFFICE MEETING STATUS",
|
||||
"show_state": true,
|
||||
"icon": "mdi:door-open",
|
||||
"icon_height": "200px"
|
||||
},
|
||||
{
|
||||
"type": "conditional",
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "state",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"state": "off"
|
||||
}
|
||||
],
|
||||
"card": {
|
||||
"show_name": true,
|
||||
"show_icon": false,
|
||||
"type": "button",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"name": "🟢 AVAILABLE - COME IN",
|
||||
"show_state": false,
|
||||
"icon_height": "100px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "conditional",
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "state",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"state": "on"
|
||||
}
|
||||
],
|
||||
"card": {
|
||||
"type": "button",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"name": "🔴 DO NOT ENTER - IN A MEETING",
|
||||
"show_state": false,
|
||||
"show_icon": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"name": "Manual Desk Toggle",
|
||||
"icon": "mdi:power"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"title": "Desk Companion",
|
||||
"content": "### System Status This dashboard displays your live office configuration. Outside of working hours, the system will seamlessly shift into motion-activated night light mode.\n"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "### Live Hardware Feed (Outside Door)"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "light.night_light",
|
||||
"name": "Door Light Color State",
|
||||
"state_content": "state",
|
||||
"state_color": true
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"show_header_toggle": false,
|
||||
"state_color": true,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "binary_sensor.night_light_occupancy",
|
||||
"name": "Night Light Motion Sensor",
|
||||
"icon": "mdi:motion-sensor"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom:mushroom-light-card",
|
||||
"entity": "light.bedroom_lights",
|
||||
"use_light_color": true,
|
||||
"tap_action": {
|
||||
"action": "none"
|
||||
},
|
||||
"hold_action": {
|
||||
"action": "none"
|
||||
},
|
||||
"double_tap_action": {
|
||||
"action": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:mushroom-light-card",
|
||||
"entity": "light.office_lamp_2",
|
||||
"use_light_color": true,
|
||||
"tap_action": {
|
||||
"action": "none"
|
||||
},
|
||||
"hold_action": {
|
||||
"action": "none"
|
||||
},
|
||||
"double_tap_action": {
|
||||
"action": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.kobo_dashboard",
|
||||
"data": {
|
||||
"config": {
|
||||
"wallpanel": {
|
||||
"enabled": true,
|
||||
"hide_toolbar": false,
|
||||
"hide_sidebar": true,
|
||||
"fullscreen": false,
|
||||
"idle_time": 0
|
||||
},
|
||||
"views": [
|
||||
{
|
||||
"type": "sections",
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "weather-forecast",
|
||||
"entity": "weather.forecast_home",
|
||||
"forecast_type": "hourly"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "grid",
|
||||
"square": false,
|
||||
"columns": 2,
|
||||
"cards": [
|
||||
{
|
||||
"type": "entity",
|
||||
"entity": "sensor.living_room_living_room_temperature",
|
||||
"name": "Living Room Temp"
|
||||
},
|
||||
{
|
||||
"type": "entity",
|
||||
"entity": "sensor.basement_temperature",
|
||||
"name": "Basement Temp"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "grid",
|
||||
"square": false,
|
||||
"columns": 2,
|
||||
"cards": [
|
||||
{
|
||||
"type": "entity",
|
||||
"entity": "sensor.pool_temperature",
|
||||
"name": "Pool Temperature"
|
||||
},
|
||||
{
|
||||
"type": "entity",
|
||||
"entity": "sensor.seatemperatures_sandbanks_today",
|
||||
"name": "Sandbanks"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "grid",
|
||||
"square": false,
|
||||
"columns": 2,
|
||||
"cards": [
|
||||
{
|
||||
"type": "entity",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"name": "In Meeting",
|
||||
"icon": "mdi:office-building",
|
||||
"state_color": true
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "light.night_light",
|
||||
"name": "Night Light",
|
||||
"show_icon": false,
|
||||
"show_state": false,
|
||||
"show_label": true,
|
||||
"label": "[[[\n if (states['light.night_light'].state === 'off') return 'Off';\n const rgb = states['light.night_light'].attributes.rgb_color;\n if (!rgb) return states['light.night_light'].state;\n if (rgb[0] > rgb[1] && rgb[0] > rgb[2]) return 'Red';\n if (rgb[1] > rgb[0] && rgb[1] > rgb[2]) return 'Green';\n return 'Other Color';\n]]]\n",
|
||||
"styles": {
|
||||
"label": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"color": "[[[\n if (states['light.night_light'].state === 'off') return '#888888';\n const rgb = states['light.night_light'].attributes.rgb_color;\n if (!rgb) return 'var(--primary-text-color)';\n if (rgb[0] > rgb[1] && rgb[0] > rgb[2]) return '#ff4d4d';\n if (rgb[1] > rgb[0] && rgb[1] > rgb[2]) return '#4dff4d';\n return 'var(--primary-text-color)';\n]]]\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"entities": [
|
||||
{
|
||||
"entity": "calendar.family_calendar",
|
||||
"show_time": true,
|
||||
"show_description": true,
|
||||
"split_multiday_events": false
|
||||
}
|
||||
],
|
||||
"weather": {
|
||||
"position": "date",
|
||||
"date": {
|
||||
"show_conditions": true,
|
||||
"show_high_temp": true,
|
||||
"show_low_temp": false,
|
||||
"show_uv_index": false,
|
||||
"uv_index_threshold": 0,
|
||||
"icon_size": "14px",
|
||||
"font_size": "12px",
|
||||
"color": "var(--primary-text-color)"
|
||||
},
|
||||
"event": {
|
||||
"show_conditions": true,
|
||||
"show_temp": true,
|
||||
"show_uv_index": false,
|
||||
"uv_index_threshold": 0,
|
||||
"icon_size": "14px",
|
||||
"font_size": "12px",
|
||||
"color": "var(--primary-text-color)"
|
||||
},
|
||||
"entity": "weather.forecast_home"
|
||||
},
|
||||
"type": "custom:calendar-card-pro"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "counter.dishwasher_wash_cycles",
|
||||
"name": "Dishwasher Filter Life",
|
||||
"min": 0,
|
||||
"max": 50,
|
||||
"needle": true,
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 10,
|
||||
"red": 30
|
||||
}
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": 6,
|
||||
"rows": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "binary_sensor.150633095332665_door",
|
||||
"name": "Dishwasher Door",
|
||||
"show_state": true,
|
||||
"show_icon": false,
|
||||
"custom_fields": {
|
||||
"custom_icon": "[[[\n let iconName = entity.state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';\n let iconColor = entity.state === 'on' ? 'var(--paper-item-icon-active-color, #fdd835)' : 'var(--paper-item-icon-color, #44739e)';\n return `<ha-icon icon=\"${iconName}\" style=\"width: 24px; height: 24px; color: ${iconColor};\"></ha-icon>`;\n]]]\n"
|
||||
},
|
||||
"styles": {
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"custom_icon s\" \"n n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "auto 1fr"
|
||||
},
|
||||
{
|
||||
"grid-template-rows": "auto auto"
|
||||
}
|
||||
],
|
||||
"card": [
|
||||
{
|
||||
"padding": "12px"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"height": "82px"
|
||||
}
|
||||
],
|
||||
"custom_fields": {
|
||||
"custom_icon": [
|
||||
{
|
||||
"justify-self": "start"
|
||||
},
|
||||
{
|
||||
"align-self": "center"
|
||||
},
|
||||
{
|
||||
"margin-right": "8px"
|
||||
},
|
||||
{
|
||||
"display": "flex"
|
||||
}
|
||||
]
|
||||
},
|
||||
"state": [
|
||||
{
|
||||
"font-size": "28px"
|
||||
},
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
},
|
||||
{
|
||||
"align-self": "center"
|
||||
},
|
||||
{
|
||||
"text-transform": "capitalize"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"justify-self": "start"
|
||||
},
|
||||
{
|
||||
"font-size": "14px"
|
||||
},
|
||||
{
|
||||
"color": "var(--secondary-text-color)"
|
||||
},
|
||||
{
|
||||
"margin-top": "4px"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.150633095332665_temperature",
|
||||
"name": "Dishwasher Temp",
|
||||
"show_state": true,
|
||||
"show_icon": false,
|
||||
"custom_fields": {
|
||||
"custom_icon": "[[[\n let temp = parseFloat(entity.state);\n let iconColor = temp > 40 ? '#ef5350' : 'var(--paper-item-icon-color, #44739e)';\n return `<ha-icon icon=\"mdi:thermometer\" style=\"width: 24px; height: 24px; color: ${iconColor};\"></ha-icon>`;\n]]]\n"
|
||||
},
|
||||
"styles": {
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"custom_icon s\" \"n n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "auto 1fr"
|
||||
},
|
||||
{
|
||||
"grid-template-rows": "auto auto"
|
||||
}
|
||||
],
|
||||
"card": [
|
||||
{
|
||||
"padding": "12px"
|
||||
},
|
||||
{
|
||||
"border-radius": "12px"
|
||||
},
|
||||
{
|
||||
"height": "82px"
|
||||
}
|
||||
],
|
||||
"custom_fields": {
|
||||
"custom_icon": [
|
||||
{
|
||||
"justify-self": "start"
|
||||
},
|
||||
{
|
||||
"align-self": "center"
|
||||
},
|
||||
{
|
||||
"margin-right": "8px"
|
||||
},
|
||||
{
|
||||
"display": "flex"
|
||||
}
|
||||
]
|
||||
},
|
||||
"state": [
|
||||
{
|
||||
"font-size": "28px"
|
||||
},
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
},
|
||||
{
|
||||
"align-self": "center"
|
||||
},
|
||||
{
|
||||
"text-transform": "capitalize"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"justify-self": "start"
|
||||
},
|
||||
{
|
||||
"font-size": "14px"
|
||||
},
|
||||
{
|
||||
"color": "var(--secondary-text-color)"
|
||||
},
|
||||
{
|
||||
"margin-top": "4px"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": 6,
|
||||
"rows": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "conditional",
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "state",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"state": "off"
|
||||
}
|
||||
],
|
||||
"card": {
|
||||
"show_name": true,
|
||||
"show_icon": false,
|
||||
"type": "button",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"name": "✅ AVAILABLE - COME IN",
|
||||
"show_state": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "conditional",
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "state",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"state": "on"
|
||||
}
|
||||
],
|
||||
"card": {
|
||||
"show_name": true,
|
||||
"show_icon": false,
|
||||
"type": "button",
|
||||
"entity": "input_boolean.in_a_meeting",
|
||||
"name": "✋ DO NOT ENTER - IN A MEETING",
|
||||
"show_state": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "grid",
|
||||
"square": false,
|
||||
"columns": 2,
|
||||
"cards": [
|
||||
{
|
||||
"type": "entity",
|
||||
"entity": "sensor.google_travel_time_work",
|
||||
"name": "Work"
|
||||
},
|
||||
{
|
||||
"type": "entity",
|
||||
"entity": "sensor.google_travel_time_parents",
|
||||
"name": "Parents"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"show_state": false,
|
||||
"show_name": false,
|
||||
"camera_view": "auto",
|
||||
"fit_mode": "cover",
|
||||
"type": "picture-entity",
|
||||
"entity": "camera.album_slideshow_google_screensaver",
|
||||
"camera_image": "camera.album_slideshow_google_screensaver"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.map",
|
||||
"data": {
|
||||
"config": {
|
||||
"strategy": {
|
||||
"type": "map"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.morning_hub",
|
||||
"data": {
|
||||
"config": {
|
||||
"title": "Morning Hub",
|
||||
"views": [
|
||||
{
|
||||
"title": "Morning",
|
||||
"path": "morning",
|
||||
"icon": "mdi:weather-sunset-up",
|
||||
"type": "sections",
|
||||
"max_columns": 3,
|
||||
"sections": [
|
||||
{
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Overview"
|
||||
},
|
||||
{
|
||||
"type": "custom:clock-weather-card",
|
||||
"entity": "weather.pirateweather"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"name": "Commute",
|
||||
"icon": "mdi:car",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"border-radius": "16px 16px 0px 0px"
|
||||
},
|
||||
{
|
||||
"padding": "12px 16px"
|
||||
},
|
||||
{
|
||||
"border-bottom": "1px solid #f1f2f6"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "30px 1fr"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"font-size": "16px"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "#1e90ff"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.google_travel_time_work",
|
||||
"show_name": true,
|
||||
"show_label": true,
|
||||
"name": "Google Maps",
|
||||
"label": "via Autoroute 13 S",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"border-radius": "0px"
|
||||
},
|
||||
{
|
||||
"padding": "12px 16px"
|
||||
},
|
||||
{
|
||||
"border-bottom": "1px solid #f1f2f6"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n stat b\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "40px 1fr auto auto"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "#4285F4"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
],
|
||||
"label": [
|
||||
{
|
||||
"font-size": "12px"
|
||||
},
|
||||
{
|
||||
"color": "gray"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
]
|
||||
},
|
||||
"custom_fields": {
|
||||
"stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]",
|
||||
"b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `<span style=\"background-color: ${bg}; color: ${clr}; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-left: 10px;\">${txt}</span>`;\n]]]\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.waze_time_waze_to_work_travel_time",
|
||||
"show_name": true,
|
||||
"show_label": true,
|
||||
"name": "Waze",
|
||||
"label": "via Autoroute 13 S",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"border-radius": "0px"
|
||||
},
|
||||
{
|
||||
"padding": "12px 16px"
|
||||
},
|
||||
{
|
||||
"border-bottom": "1px solid #f1f2f6"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n stat b\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "40px 1fr auto auto"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "#33ccff"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
],
|
||||
"label": [
|
||||
{
|
||||
"font-size": "12px"
|
||||
},
|
||||
{
|
||||
"color": "gray"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
]
|
||||
},
|
||||
"custom_fields": {
|
||||
"stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]",
|
||||
"b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `<span style=\"background-color: ${bg}; color: ${clr}; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-left: 10px;\">${txt}</span>`;\n]]]\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.google_travel_time_work",
|
||||
"show_name": true,
|
||||
"show_label": true,
|
||||
"name": "[[[ \n var mins = Math.round(parseFloat(entity.state));\n var status = mins < 35 ? 'Traffic is flowing normally' : (mins < 45 ? 'Minor traffic delays' : 'Heavy traffic alerts');\n return `${status} • ${mins} min`;\n]]]\n",
|
||||
"label": "Typical commute: ~25 min",
|
||||
"icon": "[[[ return parseFloat(entity.state) < 35 ? 'mdi:check-circle' : (parseFloat(entity.state) < 45 ? 'mdi:alert-circle' : 'mdi:alert-octagon'); ]]]",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"border-radius": "0px 0px 16px 16px"
|
||||
},
|
||||
{
|
||||
"padding": "12px 16px"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "40px 1fr"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "[[[ return parseFloat(entity.state) < 35 ? '#2ecc71' : (parseFloat(entity.state) < 45 ? '#e67e22' : '#e74c3c'); ]]]"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "500"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
],
|
||||
"label": [
|
||||
{
|
||||
"font-size": "12px"
|
||||
},
|
||||
{
|
||||
"color": "gray"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Control"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"name": "Quick Actions",
|
||||
"icon": "mdi:rocket-launch",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background": "none"
|
||||
},
|
||||
{
|
||||
"box-shadow": "none"
|
||||
},
|
||||
{
|
||||
"padding": "4px 0px"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "30px 1fr"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"font-size": "16px"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "#9b59b6"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"square": false,
|
||||
"columns": 1,
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"name": "Open Waze",
|
||||
"icon": "mdi:waze",
|
||||
"tap_action": {
|
||||
"action": "url",
|
||||
"url_path": "https://waze.com/ul"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background-color": "#3498db"
|
||||
},
|
||||
{
|
||||
"border-radius": "16px"
|
||||
},
|
||||
{
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"padding": "18px 16px"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "40px 1fr"
|
||||
},
|
||||
{
|
||||
"justify-items": "start"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"font-size": "16px"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"width": "26px"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"name": "Open Google Maps",
|
||||
"icon": "mdi:google-maps",
|
||||
"tap_action": {
|
||||
"action": "url",
|
||||
"url_path": "https://maps.google.com"
|
||||
},
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background-color": "#2ecc71"
|
||||
},
|
||||
{
|
||||
"border-radius": "16px"
|
||||
},
|
||||
{
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"padding": "18px 16px"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "40px 1fr"
|
||||
},
|
||||
{
|
||||
"justify-items": "start"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"font-size": "16px"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"width": "26px"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Schedule"
|
||||
},
|
||||
{
|
||||
"title": "Today's Calendar",
|
||||
"entities": [
|
||||
"calendar.family_calendar"
|
||||
],
|
||||
"default_view": "agenda",
|
||||
"first_day_of_week": 0,
|
||||
"week_days": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"week_start_hour": 0,
|
||||
"week_end_hour": 23,
|
||||
"lock_schedule_hours": false,
|
||||
"hide_the_past": false,
|
||||
"past_event_mode": "none",
|
||||
"disable_swipe_controls": false,
|
||||
"show_all_events_month": false,
|
||||
"show_all_details_month": false,
|
||||
"hide_empty_days": true,
|
||||
"agenda_compact_events": false,
|
||||
"shorten_event_times": false,
|
||||
"time_zone": "",
|
||||
"display_full_weekday_names": false,
|
||||
"compact_width": false,
|
||||
"show_current_time_bar": false,
|
||||
"show_event_location": false,
|
||||
"use_short_location": false,
|
||||
"event_location_font_size": 9,
|
||||
"background_opacity": 0,
|
||||
"header_background_opacity": 0,
|
||||
"event_calendar_friendly_name": false,
|
||||
"event_title_prefix": "none",
|
||||
"combine_style": "bars",
|
||||
"combine_background": "primary",
|
||||
"event_color_mode": "classic",
|
||||
"event_neutral_background": "#F8F3E9",
|
||||
"event_tint_opacity": 80,
|
||||
"event_color_bar_width": 18,
|
||||
"day_badges": [],
|
||||
"day_badge_layout_week": "inline",
|
||||
"hide_calendars": true,
|
||||
"hide_header": true,
|
||||
"hide_year": false,
|
||||
"hide_controls": false,
|
||||
"hide_navigation_buttons": false,
|
||||
"hide_add_event_button": false,
|
||||
"hide_view_selector": false,
|
||||
"hide_dark_mode_toggle": false,
|
||||
"show_dashboard_nav_button": false,
|
||||
"header_dashboard_path": null,
|
||||
"header_weather_sensor": "",
|
||||
"calendar_person_entities": {},
|
||||
"default_hidden_calendars": [],
|
||||
"color_scheme": "auto",
|
||||
"enable_event_management": true,
|
||||
"event_modal_size": "medium",
|
||||
"type": "custom:daylight-calendar-card",
|
||||
"rolling_days": 7,
|
||||
"compact_height": true,
|
||||
"card_mod": {
|
||||
"style": "ha-card {\n border-radius: 18px !important;\n padding: 16px !important;\n background-color: transparent !important;\n box-shadow: none !important;\n border: none !important;\n}\n.daylight-header-title {\n font-weight: bold !important;\n font-size: 18px !important;\n color: var(--primary-text-color) !important;\n}\n.daylight-event-wrapper {\n background: #ffffff !important;\n border: 1px solid #e1e4e8 !important;\n border-radius: 14px !important;\n margin-bottom: 12px !important;\n padding: 14px 16px !important;\n box-shadow: 0 4px 12px rgba(0,0,0,0.02) !important;\n border-left: 5px solid var(--daylight-event-color, #2196f3) !important;\n}\n.daylight-event-title {\n font-weight: bold !important;\n font-size: 15px !important;\n color: #2c3e50 !important;\n}\n.daylight-event-time {\n font-size: 12px !important;\n color: #95a5a6 !important;\n margin-bottom: 2px !important;\n}\n"
|
||||
},
|
||||
"hide_calendar_names": false,
|
||||
"grid_options": {
|
||||
"rows": 5,
|
||||
"columns": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "statistics-graph",
|
||||
"entities": [
|
||||
"sensor.google_travel_time_work"
|
||||
],
|
||||
"days_to_show": 1,
|
||||
"period": "hour",
|
||||
"chart_type": "line",
|
||||
"stat_types": [
|
||||
"mean"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "statistics-graph",
|
||||
"entities": [
|
||||
"sensor.waze_time_waze_to_parents"
|
||||
],
|
||||
"days_to_show": 1,
|
||||
"period": "hour",
|
||||
"chart_type": "line",
|
||||
"stat_types": [
|
||||
"mean"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"name": "[[[\n const hour = new Date().getHours();\n if (hour >= 5 && hour < 12) {\n return \"Good Morning, Franco!\";\n } else if (hour >= 12 && hour < 17) {\n return \"Good Afternoon, Franco!\";\n } else {\n return \"Good Evening, Franco!\";\n }\n]]]",
|
||||
"label": "[[[ const lnow = new Date(); return lnow.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); ]]]",
|
||||
"show_label": true,
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"background": "[[[\n const hour = new Date().getHours();\n \n if (hour >= 5 && hour < 11) {\n // Sunrise: Soft orange to warm pink dawn gradient\n return 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #feada6 100%)';\n } else if (hour >= 11 && hour < 16) {\n // Mid-day: Bright, vibrant clear sky blue gradient\n return 'linear-gradient(135deg, #2980b9 0%, #6dd5fa 50%, #ffffff 100%)';\n } else if (hour >= 16 && hour < 21) {\n // Sunset: Deep twilight purple to vibrant orange gradient\n return 'linear-gradient(135deg, #111827 0%, #701a75 40%, #f59e0b 100%)';\n } else {\n // Night: Deep cosmic midnight blue and space gray gradient\n return 'linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #334155 100%)';\n }\n]]]"
|
||||
},
|
||||
{
|
||||
"background-size": "cover"
|
||||
},
|
||||
{
|
||||
"background-position": "center"
|
||||
},
|
||||
{
|
||||
"height": "110px"
|
||||
},
|
||||
{
|
||||
"border-radius": "18px"
|
||||
},
|
||||
{
|
||||
"padding": "16px"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"justify-self": "start"
|
||||
},
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"font-size": "22px"
|
||||
},
|
||||
{
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"text-shadow": "0px 2px 4px rgba(0,0,0,0.2)"
|
||||
}
|
||||
],
|
||||
"label": [
|
||||
{
|
||||
"justify-self": "start"
|
||||
},
|
||||
{
|
||||
"font-size": "14px"
|
||||
},
|
||||
{
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"opacity": "0.9"
|
||||
},
|
||||
{
|
||||
"text-shadow": "0px 1px 3px rgba(0,0,0,0.2)"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"show_state": false,
|
||||
"show_name": false,
|
||||
"camera_view": "auto",
|
||||
"fit_mode": "cover",
|
||||
"type": "picture-entity",
|
||||
"entity": "camera.album_slideshow_google_screensaver",
|
||||
"camera_image": "camera.album_slideshow_google_screensaver"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "New section"
|
||||
},
|
||||
{
|
||||
"type": "custom:power-gauge-card",
|
||||
"entity": "sensor.meter00_power",
|
||||
"max": 10000,
|
||||
"grid_options": {
|
||||
"columns": 9,
|
||||
"rows": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.morning_ipad",
|
||||
"data": {
|
||||
"config": {
|
||||
"wallpanel": {
|
||||
"enabled": true,
|
||||
"hide_toolbar": false,
|
||||
"hide_sidebar": true,
|
||||
"fullscreen": false,
|
||||
"idle_time": 0
|
||||
},
|
||||
"title": "Morning Hub",
|
||||
"views": [
|
||||
{
|
||||
"title": "Morning",
|
||||
"path": "morning",
|
||||
"icon": "mdi:weather-sunset-up",
|
||||
"type": "sections",
|
||||
"max_columns": 3,
|
||||
"sections": [
|
||||
{
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Overview"
|
||||
},
|
||||
{
|
||||
"type": "custom:clock-weather-card",
|
||||
"entity": "weather.pirateweather"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"name": "Commute",
|
||||
"icon": "mdi:car",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"border-radius": "16px 16px 0px 0px"
|
||||
},
|
||||
{
|
||||
"padding": "12px 16px"
|
||||
},
|
||||
{
|
||||
"border-bottom": "1px solid #f1f2f6"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "30px 1fr"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"font-size": "16px"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "#1e90ff"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.google_travel_time_work",
|
||||
"show_name": true,
|
||||
"show_label": true,
|
||||
"name": "Google Maps",
|
||||
"label": "via Autoroute 13 S",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"border-radius": "0px"
|
||||
},
|
||||
{
|
||||
"padding": "12px 16px"
|
||||
},
|
||||
{
|
||||
"border-bottom": "1px solid #f1f2f6"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n stat b\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "40px 1fr auto auto"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "#4285F4"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
],
|
||||
"label": [
|
||||
{
|
||||
"font-size": "12px"
|
||||
},
|
||||
{
|
||||
"color": "gray"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
]
|
||||
},
|
||||
"custom_fields": {
|
||||
"stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]",
|
||||
"b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `<span style=\"background-color: ${bg}; color: ${clr}; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-left: 10px;\">${txt}</span>`;\n]]]\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.waze_time_waze_to_work_travel_time",
|
||||
"show_name": true,
|
||||
"show_label": true,
|
||||
"name": "Waze",
|
||||
"label": "via Autoroute 13 S",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"border-radius": "0px"
|
||||
},
|
||||
{
|
||||
"padding": "12px 16px"
|
||||
},
|
||||
{
|
||||
"border-bottom": "1px solid #f1f2f6"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n stat b\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "40px 1fr auto auto"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "#33ccff"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "bold"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
],
|
||||
"label": [
|
||||
{
|
||||
"font-size": "12px"
|
||||
},
|
||||
{
|
||||
"color": "gray"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
]
|
||||
},
|
||||
"custom_fields": {
|
||||
"stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]",
|
||||
"b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `<span style=\"background-color: ${bg}; color: ${clr}; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-left: 10px;\">${txt}</span>`;\n]]]\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:button-card",
|
||||
"entity": "sensor.google_travel_time_work",
|
||||
"show_name": true,
|
||||
"show_label": true,
|
||||
"name": "[[[ \n var mins = Math.round(parseFloat(entity.state));\n var status = mins < 35 ? 'Traffic normally' : (mins < 45 ? 'Minor traffic delays' : 'Heavy traffic alerts');\n return `${status} • ${mins} min`;\n]]]\n",
|
||||
"label": "Typical commute: ~25 min",
|
||||
"icon": "[[[ return parseFloat(entity.state) < 35 ? 'mdi:check-circle' : (parseFloat(entity.state) < 45 ? 'mdi:alert-circle' : 'mdi:alert-octagon'); ]]]",
|
||||
"styles": {
|
||||
"card": [
|
||||
{
|
||||
"border-radius": "0px 0px 16px 16px"
|
||||
},
|
||||
{
|
||||
"padding": "12px 16px"
|
||||
}
|
||||
],
|
||||
"grid": [
|
||||
{
|
||||
"grid-template-areas": "\"i n\""
|
||||
},
|
||||
{
|
||||
"grid-template-columns": "40px 1fr"
|
||||
}
|
||||
],
|
||||
"icon": [
|
||||
{
|
||||
"color": "[[[ return parseFloat(entity.state) < 35 ? '#2ecc71' : (parseFloat(entity.state) < 45 ? '#e67e22' : '#e74c3c'); ]]]"
|
||||
}
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"font-weight": "500"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
],
|
||||
"label": [
|
||||
{
|
||||
"font-size": "12px"
|
||||
},
|
||||
{
|
||||
"color": "gray"
|
||||
},
|
||||
{
|
||||
"justify-self": "start"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Control"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Schedule"
|
||||
},
|
||||
{
|
||||
"title": "Today's Calendar",
|
||||
"entities": [
|
||||
"calendar.family_calendar"
|
||||
],
|
||||
"default_view": "agenda",
|
||||
"first_day_of_week": 0,
|
||||
"week_days": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"week_start_hour": 0,
|
||||
"week_end_hour": 23,
|
||||
"lock_schedule_hours": false,
|
||||
"hide_the_past": false,
|
||||
"past_event_mode": "none",
|
||||
"disable_swipe_controls": false,
|
||||
"show_all_events_month": false,
|
||||
"show_all_details_month": false,
|
||||
"hide_empty_days": true,
|
||||
"agenda_compact_events": false,
|
||||
"shorten_event_times": false,
|
||||
"time_zone": "",
|
||||
"display_full_weekday_names": false,
|
||||
"compact_width": false,
|
||||
"show_current_time_bar": false,
|
||||
"show_event_location": false,
|
||||
"use_short_location": false,
|
||||
"event_location_font_size": 9,
|
||||
"background_opacity": 0,
|
||||
"header_background_opacity": 0,
|
||||
"event_calendar_friendly_name": false,
|
||||
"event_title_prefix": "none",
|
||||
"combine_style": "bars",
|
||||
"combine_background": "primary",
|
||||
"event_color_mode": "classic",
|
||||
"event_neutral_background": "#F8F3E9",
|
||||
"event_tint_opacity": 80,
|
||||
"event_color_bar_width": 18,
|
||||
"day_badges": [],
|
||||
"day_badge_layout_week": "inline",
|
||||
"hide_calendars": true,
|
||||
"hide_header": true,
|
||||
"hide_year": false,
|
||||
"hide_controls": false,
|
||||
"hide_navigation_buttons": false,
|
||||
"hide_add_event_button": false,
|
||||
"hide_view_selector": false,
|
||||
"hide_dark_mode_toggle": false,
|
||||
"show_dashboard_nav_button": false,
|
||||
"header_dashboard_path": null,
|
||||
"header_weather_sensor": "",
|
||||
"calendar_person_entities": {},
|
||||
"default_hidden_calendars": [],
|
||||
"color_scheme": "auto",
|
||||
"enable_event_management": true,
|
||||
"event_modal_size": "medium",
|
||||
"type": "custom:daylight-calendar-card",
|
||||
"rolling_days": 7,
|
||||
"compact_height": true,
|
||||
"card_mod": {
|
||||
"style": "ha-card {\n border-radius: 18px !important;\n padding: 16px !important;\n background-color: transparent !important;\n box-shadow: none !important;\n border: none !important;\n}\n.daylight-header-title {\n font-weight: bold !important;\n font-size: 18px !important;\n color: var(--primary-text-color) !important;\n}\n.daylight-event-wrapper {\n background: #ffffff !important;\n border: 1px solid #e1e4e8 !important;\n border-radius: 14px !important;\n margin-bottom: 12px !important;\n padding: 14px 16px !important;\n box-shadow: 0 4px 12px rgba(0,0,0,0.02) !important;\n border-left: 5px solid var(--daylight-event-color, #2196f3) !important;\n}\n.daylight-event-title {\n font-weight: bold !important;\n font-size: 15px !important;\n color: #2c3e50 !important;\n}\n.daylight-event-time {\n font-size: 12px !important;\n color: #95a5a6 !important;\n margin-bottom: 2px !important;\n}\n"
|
||||
},
|
||||
"hide_calendar_names": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"show_state": false,
|
||||
"show_name": false,
|
||||
"camera_view": "auto",
|
||||
"fit_mode": "cover",
|
||||
"type": "picture-entity",
|
||||
"entity": "camera.album_slideshow_google_screensaver",
|
||||
"camera_image": "camera.album_slideshow_google_screensaver"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.network_cellphone",
|
||||
"data": {
|
||||
"config": {
|
||||
"views": [
|
||||
{
|
||||
"type": "sections",
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"icon": "mdi:fridge",
|
||||
"heading": "Network Status",
|
||||
"heading_style": "title"
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "entities",
|
||||
"show_header_toggle": false,
|
||||
"state_color": true,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_connected_devices",
|
||||
"name": "Active Clients",
|
||||
"icon": "mdi:laptop"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wan_download_speed",
|
||||
"name": "Internet Download",
|
||||
"icon": "mdi:arrow-down"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wan_upload_speed",
|
||||
"name": "Internet Upload",
|
||||
"icon": "mdi:arrow-up"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wired_download_speed",
|
||||
"name": "Wired D/L",
|
||||
"icon": "mdi:lan-connect"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wired_upload_speed",
|
||||
"name": "Wired U/L",
|
||||
"icon": "mdi:lan-pending"
|
||||
}
|
||||
],
|
||||
"card_mod": {
|
||||
"style": "ha-card {\n background: #0d0e12 !important;\n border-left: 1px solid #1a1d26 !important;\n border-right: 1px solid #1a1d26 !important;\n border-top: none !important;\n border-bottom: none !important;\n border-radius: 0px !important;\n box-shadow: none !important;\n --primary-text-color: #f1f5f9;\n --secondary-text-color: #94a3b8;\n --paper-item-icon-color: #3b82f6;\n}\n.card-content {\n padding: 16px 20px 4px 20px !important;\n}\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom:layout-card",
|
||||
"layout_type": "grid",
|
||||
"grid_options": {
|
||||
"columns": "full",
|
||||
"rows": 3
|
||||
},
|
||||
"layout": {
|
||||
"grid-template-columns": "3fr 1fr",
|
||||
"grid-gap": "12px"
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:mini-graph-card",
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wan_download_speed",
|
||||
"name": "Download",
|
||||
"color": null
|
||||
},
|
||||
{
|
||||
"entity": "sensor.gt_ax11000_wan_upload_speed",
|
||||
"name": "Upload",
|
||||
"color": null
|
||||
}
|
||||
],
|
||||
"hours_to_show": 6,
|
||||
"points_per_hour": 6,
|
||||
"line_width": 4,
|
||||
"show": {
|
||||
"name": false,
|
||||
"icon": false,
|
||||
"state": true,
|
||||
"legend": true,
|
||||
"labels": false
|
||||
},
|
||||
"card_mod": {
|
||||
"style": "ha-card {\n background: #0d0e12 !important;\n border: 1px solid #1a1d26 !important;\n border-radius: 12px !important;\n padding: 12px !important;\n height: 100% !important;\n box-sizing: border-box;\n}\n.header {\n padding: 0 !important;\n}\n.states {\n font-size: 14px !important;\n font-weight: 600;\n}\n.states--secondary {\n font-size: 12px !important;\n}\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "vertical-stack",
|
||||
"cards": [
|
||||
{
|
||||
"type": "sensor",
|
||||
"entity": "sensor.gt_ax11000_temperature_cpu",
|
||||
"name": "CPU Temp",
|
||||
"graph": "none",
|
||||
"card_mod": {
|
||||
"style": "ha-card {\n background: #0d0e12 !important;\n border: 1px solid #1a1d26 !important;\n border-radius: 12px !important;\n padding: 8px 12px !important;\n box-sizing: border-box;\n margin-bottom: 4px !important;\n}\n.name {\n font-size: 12px !important;\n color: #808080 !important;\n}\n.value {\n font-size: 16px !important;\n font-weight: 600 !important;\n}\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.gt_ax11000_cpu",
|
||||
"grid_options": {
|
||||
"columns": 6,
|
||||
"rows": "auto"
|
||||
},
|
||||
"name": "CPU Usage"
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.gt_ax11000_ram",
|
||||
"grid_options": {
|
||||
"columns": 6,
|
||||
"rows": "auto"
|
||||
},
|
||||
"name": "RAM Ussage"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace_dashboards",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "map",
|
||||
"icon": "mdi:map",
|
||||
"title": "Map",
|
||||
"url_path": "map",
|
||||
"require_admin": false,
|
||||
"show_in_sidebar": false,
|
||||
"mode": "storage"
|
||||
},
|
||||
{
|
||||
"id": "dashboard_home",
|
||||
"show_in_sidebar": true,
|
||||
"title": "Home",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "dashboard-home"
|
||||
},
|
||||
{
|
||||
"id": "alarm_section",
|
||||
"show_in_sidebar": true,
|
||||
"title": "Alarm Section",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "alarm-section"
|
||||
},
|
||||
{
|
||||
"id": "dashboard_temp",
|
||||
"show_in_sidebar": true,
|
||||
"title": "Stats",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "dashboard-temp"
|
||||
},
|
||||
{
|
||||
"id": "kobo_dashboard",
|
||||
"show_in_sidebar": false,
|
||||
"title": "Kobo Dashboard",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "kobo-dashboard"
|
||||
},
|
||||
{
|
||||
"id": "morning_hub",
|
||||
"show_in_sidebar": true,
|
||||
"title": "Morning Hub",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "morning-hub"
|
||||
},
|
||||
{
|
||||
"id": "morning_ipad",
|
||||
"show_in_sidebar": false,
|
||||
"title": "Morning iPad",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "morning-ipad"
|
||||
},
|
||||
{
|
||||
"id": "network_cellphone",
|
||||
"show_in_sidebar": true,
|
||||
"title": "Network CellPhone",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "network-cellphone"
|
||||
},
|
||||
{
|
||||
"id": "in_meeting",
|
||||
"show_in_sidebar": true,
|
||||
"title": "In Meeting",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "in-meeting"
|
||||
},
|
||||
{
|
||||
"id": "dashboard_music",
|
||||
"show_in_sidebar": true,
|
||||
"title": "Music",
|
||||
"require_admin": false,
|
||||
"mode": "storage",
|
||||
"url_path": "dashboard-music"
|
||||
},
|
||||
{
|
||||
"id": "printer_stats",
|
||||
"url_path": "printer-stats",
|
||||
"title": "3D Printer",
|
||||
"require_admin": false,
|
||||
"show_in_sidebar": true,
|
||||
"icon": "mdi:printer-3d-nozzle",
|
||||
"mode": "storage"
|
||||
},
|
||||
{
|
||||
"id": "ender5_printer",
|
||||
"url_path": "ender5-printer",
|
||||
"title": "Ender 5",
|
||||
"icon": "mdi:printer-3d-nozzle",
|
||||
"show_in_sidebar": true,
|
||||
"require_admin": false,
|
||||
"mode": "storage"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace_resources",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "cc55565add58413bbcadba9f9cf8caba",
|
||||
"url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375522",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "387073c1f6fe4a658c6ca7e185b1134c",
|
||||
"url": "/hacsfiles/sunsynk-power-flow-card/sunsynk-power-flow-card.js?hacstag=613588535733",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "52ed5bcf36d24b36a1f1b3584ef7b64c",
|
||||
"url": "/hacsfiles/power-flow-card-plus/power-flow-card-plus.js?hacstag=618081815037",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6bde3dd4cdd0478d9e77c0d68d093cfd",
|
||||
"url": "/hacsfiles/energy-sankey/energy-sankey.js?hacstag=875798863104",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "ee7be1b18ae84060a5d515640f6e5739",
|
||||
"url": "/seatemperatures_frontend/sea-temperatures-card.js?v=3.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "53837b51a371459b95ffc0989ce615fb",
|
||||
"url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749400",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "c86cd6c3d8eb451ebde1a41ad5a1dd33",
|
||||
"url": "/hacsfiles/lovelace-auto-entities/auto-entities.js?hacstag=1677445841161",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0b28e2d01d0d4033b2f0a339cc7d86c1",
|
||||
"url": "/ha_washdata/ha-washdata-card.js?v=8725e1d48d",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "8ce0dbd60e7641be90f2e08594d1db2e",
|
||||
"url": "/hacsfiles/HA-Firemote/HA-Firemote.js?hacstag=536329656419",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6ad5d0d7a4c9435a85476e49d22d4df4",
|
||||
"url": "/hacsfiles/versatile-thermostat-ui-card/versatile-thermostat-ui-card.js?hacstag=714354847320",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0476eb038c9a47a7b654a078b824a9d1",
|
||||
"url": "/hacsfiles/scheduler-card/scheduler-card.js?hacstag=2862701574018",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5182b0b7bbc94cd7956a7eaf1389ebd1",
|
||||
"url": "/hacsfiles/lovelace-thermostat-pro-timeline/thermostat-pro-timeline.js?hacstag=1072526491300",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "193c54e0fb0947dabbae7a889f0b5f46",
|
||||
"url": "/local/thermostat-pro-timeline.js?v=210555539759382",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9a535448bf5645889b43fbdf481bb225",
|
||||
"url": " /local/community/homie-dashboard/homie-dashboard.js",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "3996b960ca1a49cd81de517c5e1b658a",
|
||||
"url": "/hacsfiles/sensor-bar-card-plus/sensor-bar-card-plus.js?hacstag=1183027678163",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
|
||||
"url": "/taskmate/taskmate-attr-resolver.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0796c92931dd458e81dc9c2677248274",
|
||||
"url": "/taskmate/taskmate-localize.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2e281920ac7409583eafff1f1db69bd",
|
||||
"url": "/taskmate/taskmate-design.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9eae9632f0734480be5edd784b030673",
|
||||
"url": "/taskmate/taskmate-badges-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5df49cd8a4eb420497b5df22cb31362c",
|
||||
"url": "/taskmate/taskmate-child-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
|
||||
"url": "/taskmate/taskmate-rewards-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "787a96f9c30548708165a48457c99feb",
|
||||
"url": "/taskmate/taskmate-approvals-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "84031597de424ad68e7c2bc66d06b72e",
|
||||
"url": "/taskmate/taskmate-points-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "02613225844746b699bfbfac9642eca2",
|
||||
"url": "/taskmate/taskmate-reorder-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "7a06a07e17ba4b31b712bc91b77360b4",
|
||||
"url": "/taskmate/taskmate-overview-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "08b42b53b33b4fa88db932455cf8d5aa",
|
||||
"url": "/taskmate/taskmate-activity-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "15907eff51784acdba88ff694b574509",
|
||||
"url": "/taskmate/taskmate-streak-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
|
||||
"url": "/taskmate/taskmate-weekly-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "289ce5943f5a4972a593697db4c96ece",
|
||||
"url": "/taskmate/taskmate-graph-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2904acee4c74a398dcc7b996c23e7e7",
|
||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "46d378736fd54a26be253f2dfb27202e",
|
||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "db11ae36457842fa826c7991e1d9c6f4",
|
||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
|
||||
"url": "/taskmate/taskmate-penalties-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1959e1629de2422daa8adaf87069c288",
|
||||
"url": "/taskmate/taskmate-bonuses-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "2548524b269c4959bc7e0e967df943af",
|
||||
"url": "/taskmate/taskmate-points-display-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "36ada49368db4187a283fbbbef45454f",
|
||||
"url": "/taskmate/taskmate-calendar-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "67c32687acd2497b90030ef141f4b6db",
|
||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
|
||||
"url": "/taskmate/taskmate-family-goal-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "e92ef0caff41454e9f49ea966ed99e41",
|
||||
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374110",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "4d1a17aa96b74064af713fc5e9334862",
|
||||
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680153",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d3c3e144a91247ce8ac524814737d7f8",
|
||||
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164662",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0922e4ba1c7c4f11969d0257689af5a3",
|
||||
"url": "/hacsfiles/button-card/button-card.js?hacstag=146194325701",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "e9bc54c181334248b39e0f8874998041",
|
||||
"url": "/hacsfiles/atomic-calendar-revive/atomic-calendar-revive.js?hacstag=2465497471031",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "c9b6518d876d437a9671194eb1919026",
|
||||
"url": "/hacsfiles/daylight-calendar-card/skylight-calendar-card.js?hacstag=1143954763470",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5612169a8e664ae5bef0aa3803a076db",
|
||||
"url": "/hacsfiles/platinum-weather-card/platinum-weather-card.js?hacstag=488086721105",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "449ff43140654e4bbe2c7b2d361dfacc",
|
||||
"url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019294",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "dab9cfff6e834886bcf4d5c7c9968c9e",
|
||||
"url": "/webrtc/webrtc-camera.js?v=v3.6.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "94255369635043dfbbf598581e56569d",
|
||||
"url": "/hacsfiles/apexcharts-card/apexcharts-card.js?hacstag=331701152223",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1251007d3e5a44a7bbced052f41b5f9c",
|
||||
"url": "/hacsfiles/mini-graph-card/mini-graph-card-bundle.js?hacstag=1512800620130",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0893e5cfeaf1486f9c44cef25048230a",
|
||||
"url": "/hacsfiles/lovelace-layout-card/layout-card.js?hacstag=156434866247",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "8713ea9c008e4270bb28085eed5be959",
|
||||
"url": "/hacsfiles/Statistics-Graph-Chart-Card/statistics-graph-chart-card.js?hacstag=1034932856402",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6c3f7d87683d4473b5dc8fe8177535f6",
|
||||
"url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807430",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9a17b155e3fb42949f2b2f64e24c42d5",
|
||||
"url": "/hacsfiles/mass-player-card/mass-player-card.js?hacstag=1043277514280",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "15981f60b53f4d5886272ea59de3861b",
|
||||
"url": "/hacsfiles/uptime-card/uptime-card.js?hacstag=3505098670160",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
||||
"url": "/taskmate/taskmate-routine-card.js?v=5.3.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "24acdbe1f5fb4bbdb2f16b69d515d549",
|
||||
"url": "/hacsfiles/ha-power-gauge/ha-power-gauge.js?hacstag=1233333695024",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9d164a6504b2427ba34497d5bf035fb4",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "630cb889ebde4f41a2caa24597e156a7",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "8202c7b2a7bd4c3e87e57bcd3c97e818",
|
||||
"url": "/local/gcode_viewer_card.js",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "422bfc05badc4909bf9edd923411071c",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "ed04ea48f42b4ea98d1553401f694350",
|
||||
"url": "/my_music_library/my-music-library-card.js?v=3.12.4",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "093ce4ff61604cd2808a61e397a4bd5a",
|
||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.8.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9227b25fc2ea4cb5be22e4436912268a",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Dishwasher",
|
||||
"device_id": 150633095332665,
|
||||
"type": 225,
|
||||
"protocol": 3,
|
||||
"ip_address": "192.168.1.83",
|
||||
"port": 6444,
|
||||
"model": "7600004A",
|
||||
"subtype": 3,
|
||||
"token": "2a146cafc4951b1317096d6dfd3bde6bc32aad40cdb9d60d51dcc99f8abbab4ed75331e2bc4e3dfb3664b4cf68b0b5c60ee4453bbe1d48619cc60c3483229395",
|
||||
"key": "0725ef104dbe450e83d0abaa3701f112d7771d10a9cb4306bc086718be8e684d"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"aliases": [
|
||||
"Google Nest Mini"
|
||||
],
|
||||
"calculation_enabled_condition": "{{ is_state('[[entity]]', 'playing') }}",
|
||||
"calculation_strategy": "linear",
|
||||
"created_at": "2022-10-05T10:14:54Z",
|
||||
"device_type": "smart_speaker",
|
||||
"linear_config": {
|
||||
"calibrate": [
|
||||
"0 -> 2.01",
|
||||
"10 -> 2.01",
|
||||
"20 -> 2.04",
|
||||
"30 -> 2.10",
|
||||
"40 -> 2.26",
|
||||
"50 -> 2.49",
|
||||
"60 -> 2.83",
|
||||
"70 -> 3.13",
|
||||
"80 -> 3.37",
|
||||
"90 -> 3.50",
|
||||
"100 -> 3.66"
|
||||
]
|
||||
},
|
||||
"measure_description": "Streamed 'Radiohead - National Anthem' using spotify, and took average measurements using the measure.py tool on different volume levels",
|
||||
"measure_device": "Shelly Plug S",
|
||||
"measure_method": "manual",
|
||||
"name": "Nest Mini",
|
||||
"standby_power": 1.65,
|
||||
"authors": [
|
||||
{
|
||||
"name": "Daniel O'Connor",
|
||||
"email": "daniel.oconnor@gmail.com",
|
||||
"github": "CloCkWeRX"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"aliases": [
|
||||
"TRADFRI bulb GU10 WS 345lm",
|
||||
"TRADFRIbulbGU10WS345lm"
|
||||
],
|
||||
"calculation_strategy": "lut",
|
||||
"created_at": "2022-01-28T15:48:57Z",
|
||||
"device_type": "light",
|
||||
"measure_description": "Measured with utils/measure script",
|
||||
"measure_device": "Shelly Plug S",
|
||||
"measure_method": "script",
|
||||
"measure_settings": {
|
||||
"SAMPLE_COUNT": 3,
|
||||
"SLEEP_TIME": 6,
|
||||
"VERSION": "master"
|
||||
},
|
||||
"name": "TRADFRI bulb GU10 345 lumen, dimmable, white spectrum",
|
||||
"standby_power": 0.2,
|
||||
"authors": [
|
||||
{
|
||||
"name": "giu889",
|
||||
"github": "giu889"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"aliases": [
|
||||
"TRADFRI bulb GU10 WW 345lm"
|
||||
],
|
||||
"calculation_strategy": "lut",
|
||||
"created_at": "2025-12-22T22:20:22Z",
|
||||
"device_type": "light",
|
||||
"measure_description": "Measured with utils/measure script. Used 40W incandescent bulb as dummy load.",
|
||||
"measure_device": "Shelly 1PM Mini",
|
||||
"measure_method": "script",
|
||||
"measure_settings": {
|
||||
"SAMPLE_COUNT": 2,
|
||||
"SLEEP_TIME": 3,
|
||||
"VERSION": "v1.19.5:docker"
|
||||
},
|
||||
"name": "TRADFRI bulb GU10 WW 345lm",
|
||||
"standby_power": 0.1,
|
||||
"authors": [
|
||||
{
|
||||
"name": "Peklaa",
|
||||
"github": "Peklaa"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"calculation_strategy": "lut",
|
||||
"created_at": "2024-04-12T08:07:57Z",
|
||||
"device_type": "light",
|
||||
"measure_description": "Measured with utils/measure script",
|
||||
"measure_device": "TP-Link Tapo P110",
|
||||
"measure_method": "script",
|
||||
"measure_settings": {
|
||||
"SAMPLE_COUNT": 4,
|
||||
"SLEEP_TIME": 5,
|
||||
"VERSION": "v1.10.0:docker"
|
||||
},
|
||||
"name": "TRADFRI bulb GU10 WS 345 lm, dimmable, white spectrum",
|
||||
"standby_power": 0.2,
|
||||
"authors": [
|
||||
{
|
||||
"name": "martinkura-svk",
|
||||
"github": "martinkura-svk"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"calculation_strategy": "lut",
|
||||
"created_at": "2025-10-28T04:32:28Z",
|
||||
"device_type": "light",
|
||||
"measure_description": "Measured with utils/measure script",
|
||||
"measure_device": "Shelly Plus Plug S",
|
||||
"measure_method": "script",
|
||||
"measure_settings": {
|
||||
"SAMPLE_COUNT": 4,
|
||||
"SLEEP_TIME": 3,
|
||||
"VERSION": "v1.17.24:docker"
|
||||
},
|
||||
"name": "Smart Bulb A80 E27 922-65 RGB (type SHRGB, model 8719514554634, 18.5 W, 220-240 V, 50/60 Hz, 2452 lm @ 4000K, CRI: 90, beam angle: 360°)",
|
||||
"standby_power": 0.31,
|
||||
"authors": [
|
||||
{
|
||||
"name": "schuppan",
|
||||
"github": "schuppan"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"aliases": [
|
||||
"SHRGB"
|
||||
],
|
||||
"calculation_strategy": "lut",
|
||||
"created_at": "2025-11-08T06:11:27Z",
|
||||
"device_type": "light",
|
||||
"measure_description": "Measured with utils/measure script",
|
||||
"measure_device": "Shelly 2.5",
|
||||
"measure_method": "script",
|
||||
"measure_settings": {
|
||||
"SAMPLE_COUNT": 2,
|
||||
"SLEEP_TIME": 3,
|
||||
"VERSION": "v1.17.24:docker"
|
||||
},
|
||||
"name": "3.5 inch recessed downlight 8W",
|
||||
"standby_power": 0.2,
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mike Debney",
|
||||
"email": "mike.debney@outlook.com",
|
||||
"github": "mike-debney"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "thermostat_timeline.json",
|
||||
"data": {
|
||||
"instances": {
|
||||
"default": {
|
||||
"schedules": {
|
||||
"climate.vt_basement": {
|
||||
"defaultTemp": 20,
|
||||
"blocks": [],
|
||||
"profiles": {},
|
||||
"activeProfile": null,
|
||||
"holiday": {
|
||||
"blocks": []
|
||||
},
|
||||
"presence": {}
|
||||
},
|
||||
"climate.climate_scheduler_climate_schedule_living_room": {
|
||||
"defaultTemp": 20,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "7ky0uyr",
|
||||
"startMin": 690,
|
||||
"endMin": 971,
|
||||
"temp": 21
|
||||
}
|
||||
],
|
||||
"profiles": {},
|
||||
"activeProfile": null,
|
||||
"holiday": {
|
||||
"blocks": []
|
||||
},
|
||||
"presence": {}
|
||||
},
|
||||
"climate.basement": {
|
||||
"defaultTemp": 20,
|
||||
"blocks": [],
|
||||
"profiles": {},
|
||||
"activeProfile": null,
|
||||
"holiday": {
|
||||
"blocks": []
|
||||
},
|
||||
"presence": {}
|
||||
},
|
||||
"climate.living_room_living_room": {
|
||||
"defaultTemp": 20,
|
||||
"blocks": [],
|
||||
"profiles": {},
|
||||
"activeProfile": null,
|
||||
"holiday": {
|
||||
"blocks": []
|
||||
},
|
||||
"presence": {}
|
||||
}
|
||||
},
|
||||
"weekdays": {},
|
||||
"profiles": {
|
||||
"climate.vt_basement": {
|
||||
"profiles": {},
|
||||
"activeProfile": null
|
||||
},
|
||||
"climate.climate_scheduler_climate_schedule_living_room": {
|
||||
"profiles": {},
|
||||
"activeProfile": null
|
||||
},
|
||||
"climate.basement": {
|
||||
"profiles": {},
|
||||
"activeProfile": null
|
||||
},
|
||||
"climate.living_room_living_room": {
|
||||
"profiles": {},
|
||||
"activeProfile": null
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"entities": [
|
||||
"climate.basement",
|
||||
"climate.living_room_living_room"
|
||||
],
|
||||
"room_use_input_number": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"room_use_temp_sensor": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"time_12h": true,
|
||||
"temp_unit": "C",
|
||||
"storage_temp_unit": "C",
|
||||
"time_source": "browser",
|
||||
"row_height": 64,
|
||||
"default_temp": 20,
|
||||
"min_temp": 5,
|
||||
"max_temp": 25,
|
||||
"weekdays_enabled": false,
|
||||
"seasonal_enabled": false,
|
||||
"seasonal_mode": "winter",
|
||||
"weekdays_mode": "weekday_weekend",
|
||||
"weekdays_view": "all_rooms_one_day",
|
||||
"weekdays_view_switch_in_timeline": false,
|
||||
"weekdays_selected_room": "",
|
||||
"per_room_defaults": false,
|
||||
"away": {
|
||||
"enabled": false,
|
||||
"persons": [],
|
||||
"target_c": 17,
|
||||
"advanced_enabled": false,
|
||||
"combos": {},
|
||||
"delay_enabled": false,
|
||||
"delay_value": 0,
|
||||
"delay_unit": "minutes"
|
||||
},
|
||||
"away_bypass": false,
|
||||
"merges": {},
|
||||
"labels": {},
|
||||
"temp_sensors": {},
|
||||
"turn_on": {},
|
||||
"presence_sensor_enabled": false,
|
||||
"presence_live_header": true,
|
||||
"presence_sensors": {},
|
||||
"presence_sensor_temps": {},
|
||||
"presence_sensor_delays": {},
|
||||
"presence_sensor_delay_units": {
|
||||
"climate.basement": "minutes",
|
||||
"climate.living_room_living_room": "minutes"
|
||||
},
|
||||
"show_pause_button": true,
|
||||
"pause_sensor_enabled": false,
|
||||
"pause_sensor_entity": "",
|
||||
"pause_indef": true,
|
||||
"pause_until_ms": 0,
|
||||
"boiler_switch": "",
|
||||
"boiler_switch_domain": "switch",
|
||||
"boiler_rooms": null,
|
||||
"boiler_on_offset": 0,
|
||||
"boiler_off_offset": 0,
|
||||
"boiler_temp_sensor": "",
|
||||
"boiler_min_temp": 20,
|
||||
"boiler_max_temp": 25,
|
||||
"boiler_multi_enabled": false,
|
||||
"boiler_room_settings": {},
|
||||
"show_room_temp": true,
|
||||
"auto_apply_enabled": true,
|
||||
"apply_on_edit": true,
|
||||
"apply_on_default_change": true,
|
||||
"backup_auto_enabled": false,
|
||||
"backup_interval_days": 1,
|
||||
"profiles_enabled": false,
|
||||
"global_profile": null,
|
||||
"sync_mode": "instant",
|
||||
"sync_delay_min": 5,
|
||||
"sync_delay_sec": 300,
|
||||
"color_ranges": {},
|
||||
"color_global": false,
|
||||
"boiler_enabled": false,
|
||||
"holidays_source": "calendar",
|
||||
"holidays_entity": "",
|
||||
"holidays_dates": [],
|
||||
"holidays_groups": [],
|
||||
"open_window": {
|
||||
"enabled": false,
|
||||
"open_delay_min": 2,
|
||||
"close_delay_min": 5,
|
||||
"sensors": {}
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"color_ranges": {},
|
||||
"color_global": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"active_instance_id": "default",
|
||||
"schedules": {
|
||||
"climate.vt_basement": {
|
||||
"defaultTemp": 20,
|
||||
"blocks": [],
|
||||
"profiles": {},
|
||||
"activeProfile": null,
|
||||
"holiday": {
|
||||
"blocks": []
|
||||
},
|
||||
"presence": {}
|
||||
},
|
||||
"climate.climate_scheduler_climate_schedule_living_room": {
|
||||
"defaultTemp": 20,
|
||||
"blocks": [
|
||||
{
|
||||
"id": "7ky0uyr",
|
||||
"startMin": 690,
|
||||
"endMin": 971,
|
||||
"temp": 21
|
||||
}
|
||||
],
|
||||
"profiles": {},
|
||||
"activeProfile": null,
|
||||
"holiday": {
|
||||
"blocks": []
|
||||
},
|
||||
"presence": {}
|
||||
},
|
||||
"climate.basement": {
|
||||
"defaultTemp": 20,
|
||||
"blocks": [],
|
||||
"profiles": {},
|
||||
"activeProfile": null,
|
||||
"holiday": {
|
||||
"blocks": []
|
||||
},
|
||||
"presence": {}
|
||||
},
|
||||
"climate.living_room_living_room": {
|
||||
"defaultTemp": 20,
|
||||
"blocks": [],
|
||||
"profiles": {},
|
||||
"activeProfile": null,
|
||||
"holiday": {
|
||||
"blocks": []
|
||||
},
|
||||
"presence": {}
|
||||
}
|
||||
},
|
||||
"weekdays": {},
|
||||
"profiles": {
|
||||
"climate.vt_basement": {
|
||||
"profiles": {},
|
||||
"activeProfile": null
|
||||
},
|
||||
"climate.climate_scheduler_climate_schedule_living_room": {
|
||||
"profiles": {},
|
||||
"activeProfile": null
|
||||
},
|
||||
"climate.basement": {
|
||||
"profiles": {},
|
||||
"activeProfile": null
|
||||
},
|
||||
"climate.living_room_living_room": {
|
||||
"profiles": {},
|
||||
"activeProfile": null
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"entities": [
|
||||
"climate.basement",
|
||||
"climate.living_room_living_room"
|
||||
],
|
||||
"room_use_input_number": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"room_use_temp_sensor": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"time_12h": true,
|
||||
"temp_unit": "C",
|
||||
"storage_temp_unit": "C",
|
||||
"time_source": "browser",
|
||||
"row_height": 64,
|
||||
"default_temp": 20,
|
||||
"min_temp": 5,
|
||||
"max_temp": 25,
|
||||
"weekdays_enabled": false,
|
||||
"seasonal_enabled": false,
|
||||
"seasonal_mode": "winter",
|
||||
"weekdays_mode": "weekday_weekend",
|
||||
"weekdays_view": "all_rooms_one_day",
|
||||
"weekdays_view_switch_in_timeline": false,
|
||||
"weekdays_selected_room": "",
|
||||
"per_room_defaults": false,
|
||||
"away": {
|
||||
"enabled": false,
|
||||
"persons": [],
|
||||
"target_c": 17,
|
||||
"advanced_enabled": false,
|
||||
"combos": {},
|
||||
"delay_enabled": false,
|
||||
"delay_value": 0,
|
||||
"delay_unit": "minutes"
|
||||
},
|
||||
"away_bypass": false,
|
||||
"merges": {},
|
||||
"labels": {},
|
||||
"temp_sensors": {},
|
||||
"turn_on": {},
|
||||
"presence_sensor_enabled": false,
|
||||
"presence_live_header": true,
|
||||
"presence_sensors": {},
|
||||
"presence_sensor_temps": {},
|
||||
"presence_sensor_delays": {},
|
||||
"presence_sensor_delay_units": {
|
||||
"climate.basement": "minutes",
|
||||
"climate.living_room_living_room": "minutes"
|
||||
},
|
||||
"show_pause_button": true,
|
||||
"pause_sensor_enabled": false,
|
||||
"pause_sensor_entity": "",
|
||||
"pause_indef": true,
|
||||
"pause_until_ms": 0,
|
||||
"boiler_switch": "",
|
||||
"boiler_switch_domain": "switch",
|
||||
"boiler_rooms": null,
|
||||
"boiler_on_offset": 0,
|
||||
"boiler_off_offset": 0,
|
||||
"boiler_temp_sensor": "",
|
||||
"boiler_min_temp": 20,
|
||||
"boiler_max_temp": 25,
|
||||
"boiler_multi_enabled": false,
|
||||
"boiler_room_settings": {},
|
||||
"show_room_temp": true,
|
||||
"auto_apply_enabled": true,
|
||||
"apply_on_edit": true,
|
||||
"apply_on_default_change": true,
|
||||
"backup_auto_enabled": false,
|
||||
"backup_interval_days": 1,
|
||||
"profiles_enabled": false,
|
||||
"global_profile": null,
|
||||
"sync_mode": "instant",
|
||||
"sync_delay_min": 5,
|
||||
"sync_delay_sec": 300,
|
||||
"color_ranges": {},
|
||||
"color_global": false,
|
||||
"boiler_enabled": false,
|
||||
"holidays_source": "calendar",
|
||||
"holidays_entity": "",
|
||||
"holidays_dates": [],
|
||||
"holidays_groups": [],
|
||||
"open_window": {
|
||||
"enabled": false,
|
||||
"open_delay_min": 2,
|
||||
"close_delay_min": 5,
|
||||
"sensors": {}
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"color_ranges": {},
|
||||
"color_global": false
|
||||
},
|
||||
"version": 177,
|
||||
"settings_version": 169,
|
||||
"colors_version": 169,
|
||||
"weekday_version": 1,
|
||||
"profile_version": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# MCP Server Usage
|
||||
|
||||
## ha-direct
|
||||
This custom MCP server provides deep Home Assistant integration. Use it with surgical precision — only for targeted operations that need its specific tools (automation/scene/script CRUD, safe config writing, entity registry changes). Prefer simpler MCP tools (homeassistant_*) or hab for broader queries and routine operations. Follow the ha-direct best-practices skill (home-assistant-best-practices) when creating or editing automations, scripts, scenes, helpers, or dashboards. This server can be toggled on or off, so it may not be in the list of available MCP servers — do not try to use it if it is not present. If I specifically ask you to use it, you should.
|
||||
|
||||
## Restart Rule
|
||||
Never restart Home Assistant without asking me first and getting my explicit approval. Even if I said "ok" to a restart earlier in the session, ask again before each subsequent restart.
|
||||
@@ -39,6 +39,48 @@ You MUST follow these rules strictly:
|
||||
- Make the change anyway
|
||||
- Try to convince them otherwise
|
||||
|
||||
## Safety Guidelines
|
||||
|
||||
- NEVER expose or display contents of `secrets.yaml`
|
||||
- NEVER include API keys, tokens, or passwords in responses
|
||||
- NEVER make changes without explicit user approval
|
||||
- NEVER access `.storage/`, `.cloud/`, or other internal directories
|
||||
- NEVER attempt to modify Home Assistant's internal databases or registries
|
||||
- NEVER parse internal JSON files for entity/device/area information
|
||||
- ALWAYS prefer MCP tools for querying runtime state over internal file access
|
||||
- ALWAYS use `call_service` through MCP rather than modifying state files
|
||||
- WARN users before changes that require restart vs reload
|
||||
- SUGGEST backing up files before major modifications
|
||||
- CHECK configuration validity when possible
|
||||
- ALWAYS confirm with user before writing, editing, or deleting any file
|
||||
|
||||
## RESTRICTED: Internal Home Assistant Directories
|
||||
|
||||
**NEVER read, modify, or directly interact with these internal directories:**
|
||||
|
||||
| Directory | Contains | Use Instead |
|
||||
|-----------|----------|-------------|
|
||||
| `.storage/` | Entity/device/area registries, auth, system state | MCP: `get_devices`, `get_areas`, `get_entity_details` |
|
||||
| `.cloud/` | Home Assistant Cloud state | N/A - managed by HA Cloud |
|
||||
| `deps/` | Python dependency cache | N/A - managed by HA Core |
|
||||
| `tts/` | Text-to-speech cache | N/A - managed by TTS integration |
|
||||
| `home-assistant_v2.db` | History SQLite database | MCP: `get_history`, `get_logbook` |
|
||||
| `home-assistant.log` | Raw system logs | MCP: `get_error_log` |
|
||||
|
||||
These contain internal Home Assistant state that:
|
||||
|
||||
1. Is managed exclusively by Home Assistant core
|
||||
2. Can corrupt your installation if modified incorrectly
|
||||
3. May be overwritten by Home Assistant at any time
|
||||
4. Has no stable schema or format guarantees
|
||||
|
||||
**For information that seems to require internal access, there is always a proper alternative:**
|
||||
|
||||
- Need entity details? -> Read configuration files OR use `get_entity_details`
|
||||
- Need device info? -> Use `get_devices` MCP tool
|
||||
- Need to check history? -> Use `get_history` MCP tool
|
||||
- Need to see errors? -> Use `get_error_log` MCP tool
|
||||
|
||||
## Environment Context
|
||||
|
||||
- You are running inside the OpenCode app
|
||||
@@ -47,64 +89,106 @@ You MUST follow these rules strictly:
|
||||
- If add-on folder access is enabled, `/addons` and `/addon_configs` are available for Home Assistant add-on development. Treat `/addon_configs` as sensitive and only inspect or modify these folders when the user explicitly asks.
|
||||
- You may have access to MCP tools for interacting with Home Assistant (check with the user)
|
||||
|
||||
## Skills: where the detailed procedures live
|
||||
|
||||
The add-on ships skills that hold the full procedure for each kind of Home
|
||||
Assistant work. They are loaded on demand with the `skill` tool, so they cost
|
||||
nothing until the task needs them. **Load the matching skill before you start** —
|
||||
each one carries current syntax, the tool to prefer, and the mistakes worth
|
||||
avoiding, none of which is repeated here.
|
||||
|
||||
| Load this skill | When the request is about |
|
||||
|---|---|
|
||||
| `home-assistant-configuration` | Writing or changing YAML: automations, scripts, scenes, templates, integrations, packages, helpers. Also validation, backups, and whether a change needs a reload or a restart. |
|
||||
| `home-assistant-troubleshooting` | Something is broken, missing, unavailable, or behaving oddly. Bounded diagnosis that ends in a recommendation, not a fix. |
|
||||
| `home-assistant-dashboard-ui` | Lovelace dashboards, views, cards, badges, themes, and screenshot verification of the result. |
|
||||
| `home-assistant-zigbee-esphome` | Zigbee/ZHA/Z2M devices, cascade renames, stale-device cleanup, mesh maps, ESPHome, and device firmware updates. |
|
||||
| `home-assistant-development` | Writing code rather than configuration: custom integrations, add-ons, native `llm.py` tool providers, MCP servers. |
|
||||
|
||||
More than one can apply — diagnose with the troubleshooting skill, then load the
|
||||
configuration skill when the user approves a fix. The consent, scope, secret and
|
||||
internal-directory rules above are always in force and are never relaxed by a
|
||||
skill.
|
||||
|
||||
## Home Context
|
||||
|
||||
The add-on assembles context about *this specific installation* and loads it before the user's first message. You do not need to fetch any of it. Depending on the user's settings, some or all of these are present:
|
||||
|
||||
- **Install briefing** — a generated snapshot: Home Assistant version, areas, entity counts per domain, how the configuration is split up, which custom components are installed. It is orientation, **not live state** — re-check anything current with the MCP tools or `hab`. It may be absent or partial when Home Assistant was still starting.
|
||||
- **`AGENTS.local.md`** — the user's own standing instructions, if they created that file. Follow them. This file (`AGENTS.md`) takes precedence where the two conflict, and the consent and safety rules above are never overridden.
|
||||
- **Decision notes** — decisions the user has confirmed about their setup, injected as a short digest.
|
||||
|
||||
### Decision Notes
|
||||
|
||||
Decision notes record *why* an installation is the way it is. That reasoning cannot be recovered by re-reading the YAML, which is exactly why it is worth storing.
|
||||
|
||||
**When a request conflicts with a note**, say so before acting. Never silently reverse a recorded decision — tell the user which note applies and ask whether they want to change it.
|
||||
|
||||
**The digest is a summary, not the whole record.** It states how many active notes it is showing; when that is fewer than the total, the notes it left out are still in force. `recall_decisions` is the authority. Before changing something that looks deliberate, odd, or redundant — an inverted switch, a disabled integration, a duplicate-looking entity — check there first. An empty search result means *that query* found nothing, never that nothing was decided; search again in different words, or with no query at all, before concluding a thing is safe to "fix".
|
||||
|
||||
**To read more**, use `recall_decisions`. The injected digest carries only the decisions themselves; the rationale and the superseded history are retrieved on demand. Check it when a note looks relevant but you need the reasoning, or when the user asks what was decided before.
|
||||
|
||||
**To record**, offer first and then wait. Say what you would store, in the words you would store it, and call `remember_decision` with `user_approved: true` only after the user agrees. A general instruction to "remember this" for the current task is not approval to write a permanent note; asking costs one sentence.
|
||||
|
||||
Worth recording:
|
||||
|
||||
- Deliberate removals and disables ("that integration was removed because it fought with X")
|
||||
- Intentional deviations from the obvious approach, and why
|
||||
- Things to leave alone
|
||||
- Constraints that will still be true in six months
|
||||
|
||||
Not worth recording — do not write these:
|
||||
|
||||
- What you did this session, or how you troubleshot something. **This is not a session log.**
|
||||
- Anything already readable from the configuration files
|
||||
- Anything the user has not explicitly approved
|
||||
|
||||
**Pinning** (`pin: true`) keeps a note in the digest when older notes stop fitting. It is for the small number of constraints where being forgotten causes real damage — something deliberately removed, something that must be left alone. Ask for the pin as well as for the note, and use it rarely: pinning everything pins nothing.
|
||||
|
||||
**Never** put passwords, tokens, or any value from `secrets.yaml` into a note. The tool rejects them, and a note is sent to the model in every future session.
|
||||
|
||||
## Home Assistant Interaction Model
|
||||
|
||||
There are three primary, safe ways to interact with Home Assistant:
|
||||
Four ways to interact, each with a job the others do badly.
|
||||
|
||||
### 1. Configuration Files (YAML)
|
||||
The standard way to define and customize Home Assistant behavior:
|
||||
- Automations, scripts, scenes, and blueprints
|
||||
- Integration and sensor configurations
|
||||
- Templates, packages, and customizations
|
||||
- Dashboard (Lovelace) definitions
|
||||
### 1. Configuration files (YAML)
|
||||
|
||||
These files are designed for user editing and are the source of truth for your Home Assistant setup.
|
||||
The source of truth for defined behaviour: automations, scripts, scenes,
|
||||
blueprints, integrations, templates, packages, customizations, and YAML-mode
|
||||
dashboards. These files are designed for editing. Load
|
||||
`home-assistant-configuration` before changing one — it carries the mandatory
|
||||
style guide, the safe-write path, and the reload/restart rules.
|
||||
|
||||
### 2. MCP Tools (Runtime API)
|
||||
Real-time interaction with the running Home Assistant instance:
|
||||
- Query current entity states and history
|
||||
- Control devices and call services
|
||||
- Validate configurations
|
||||
- Diagnose issues and detect anomalies
|
||||
- Report OpenCode/HA agent capability status with `get_agent_capabilities`
|
||||
### 2. MCP tools (runtime API)
|
||||
|
||||
### Native Home Assistant LLM Platform
|
||||
Home Assistant is developing a native `llm` integration where Core integrations and custom integrations can expose curated tools through `<integration>/llm.py` and registered LLM APIs. New Home Assistant builds may also expose those APIs over native MCP endpoints such as `/api/mcp/<API ID>`; the built-in Assist API uses `/api/mcp/assist`. This is complementary to OpenCode MCP, not a replacement.
|
||||
Real-time interaction with the running instance:
|
||||
|
||||
- If the optional `homeassistant_native` MCP server is available, prefer it for requests that fit the configured native Home Assistant LLM API because those tools are curated by Home Assistant.
|
||||
- Use OpenCode MCP for configuration editing, safe writes, validation, admin/dev workflows, screenshots, updates, ESPHome, `hab`, Zigbee tasks, add-on development, and Home Assistant documentation lookup.
|
||||
- Use `get_agent_capabilities` or `ha://agent/capabilities` to check whether the running HA instance reports the native `llm` component and native MCP endpoints.
|
||||
- Use `get_home_context` for compact area/domain/entity understanding before broad state dumps.
|
||||
- Use `get_ha_llm_development_guide` when helping develop or review a custom integration's native `<integration>/llm.py` provider.
|
||||
- Do not assume this add-on can register tools directly with HA's native `llm` platform; native tool registration is internal to HA integrations/custom integrations. The add-on can consume configured native LLM APIs through native MCP when Home Assistant exposes them.
|
||||
- `get_states`, `search_entities`, `get_entity_details`, `get_home_context` — current state and compact area/domain/entity context. Prefer `get_home_context` over broad state dumps.
|
||||
- `call_service` — control devices (with confirmation), and read from services that answer with data (`recorder.get_statistics`, `weather.get_forecasts`, `calendar.get_events`, `todo.get_items`); the response comes back automatically
|
||||
- `get_history`, `get_logbook`, `get_calendar_events` — historical and calendar data; supplied timestamps must include `Z` or a UTC offset
|
||||
- `get_devices`, `get_areas` — device and area registry
|
||||
- `write_config_safe`, `validate_config`, `check_config_syntax` — safe config writing with validation, content protection and backup
|
||||
- `get_integration_docs`, `get_breaking_changes` — current syntax, before writing any integration configuration
|
||||
- `diagnose_entity`, `get_error_log`, `detect_anomalies`, `get_suggestions` — diagnosis
|
||||
- `get_supervisor_health`, `get_supervisor_resolution`, `get_backup_posture`, `get_store_audit`, `get_supervisor_metrics`, `get_support_logs` — bounded, credential-redacted system evidence
|
||||
- `remember_decision`, `recall_decisions`, `supersede_decision` — decision notes
|
||||
- `watch_firmware_update`, `get_available_updates`, `update_component` — updates
|
||||
- `screenshot_url` — visual verification (requires the `screenshot_enabled` option)
|
||||
- `get_agent_capabilities`, `get_ha_llm_development_guide` — capability and native-LLM development information
|
||||
|
||||
Which tools exist depends on the add-on's MCP tool profile. If a tool you expect
|
||||
is missing, the profile is reduced — say so instead of working around it.
|
||||
|
||||
### 3. hab CLI (Home Assistant Builder)
|
||||
A CLI tool designed for AI agents to manage Home Assistant. Run `hab` commands via the terminal:
|
||||
- **Entity management**: `hab entity list`, `hab entity get light.living_room`, `hab entity logbook sensor.power --start 2h`
|
||||
- **Service calls**: `hab action call light.turn_on --entity light.living_room --data '{"brightness": 200}'`
|
||||
- **Automation CRUD**: `hab automation list`, `hab automation create`, `hab automation delete`
|
||||
- **Dashboard management**: `hab dashboard list`, `hab dashboard view create`
|
||||
- **Area/floor/zone/label**: `hab area list`, `hab area create "Kitchen"`
|
||||
- **Helpers**: `hab helper list`, `hab helper create`
|
||||
- **Scripts**: `hab script list`, `hab script create`
|
||||
- **Scenes**: `hab scene list`, `hab scene create`, `hab scene activate "Movie Time"`
|
||||
- **Blueprints**: `hab blueprint list`
|
||||
- **Backups**: `hab backup list`, `hab backup create`
|
||||
- **System**: `hab system info`, `hab system health`, `hab overview`
|
||||
- **Devices**: `hab device list`
|
||||
- **People**: `hab person list`, `hab person create`, `hab person update`
|
||||
- **Categories**: `hab category list`, `hab category assign --entity light.kitchen`
|
||||
- **To-do lists**: `hab todo list`, `hab todo item list todo.shopping`, `hab todo item add todo.shopping "Milk"`
|
||||
- **Notifications**: `hab notification list`, `hab notification create --message "Hello" --title "Alert"`
|
||||
- **Integrations**: `hab integration list`, `hab integration reload hue`, `hab integration disable mqtt`
|
||||
- **Repairs**: `hab repairs list`, `hab repairs ignore <issue_id>`
|
||||
- **Events**: `hab event list`, `hab event fire my_custom_event --data '{"key": "value"}'`
|
||||
- **Templates**: `hab template render --expression "{{ states('sensor.temperature') }}"`
|
||||
- **Search**: `hab search related`
|
||||
|
||||
`hab` outputs human-readable text by default. Use `--json` for structured JSON output (ideal for parsing).
|
||||
`hab` is pre-authenticated via the Supervisor token - no login required.
|
||||
Run `hab --help` or `hab <command> --help` for full usage details.
|
||||
A CLI designed for AI agents, pre-authenticated via the Supervisor token. It is
|
||||
the primary path for dashboards, areas/floors/zones/labels, helpers, scripts,
|
||||
scenes, blueprints, backups, people, categories, to-do lists, notifications,
|
||||
integrations, repairs, events and templates — the registry-level work that has
|
||||
no YAML file behind it.
|
||||
|
||||
`hab` prints human-readable text by default; use `--json` for structured output.
|
||||
Run `hab --help` or `hab <command> --help` for full usage.
|
||||
|
||||
<!-- HAB_LIVE_HELP_START -->
|
||||
```
|
||||
@@ -180,33 +264,16 @@ Use "hab [command] --help" for more information about a command.
|
||||
```
|
||||
<!-- HAB_LIVE_HELP_END -->
|
||||
|
||||
**Use configuration files when:** defining behavior, creating automations, setting up integrations
|
||||
**Use MCP tools when:** checking current state, safe config writing, anomaly detection, entity diagnostics
|
||||
**Use hab CLI when:** managing dashboards, areas, helpers, backups, blueprints, and bulk admin operations
|
||||
### 4. zigporter CLI (Zigbee toolkit)
|
||||
|
||||
### 4. zigporter CLI (Zigbee Toolkit)
|
||||
A CLI for Zigbee device management in Home Assistant. Handles cascade renames (updating entity IDs across automations, scripts, scenes, and all Lovelace dashboards atomically), device inspection, stale device cleanup, and Zigbee mesh visualization.
|
||||
Zigbee device management, and the only tool here that **cascades a rename**
|
||||
across automations, scripts, scenes and every Lovelace dashboard atomically.
|
||||
`hab` renames one thing and leaves the references dangling. Also handles device
|
||||
inspection across ZHA/Z2M/HA, stale-device cleanup, and mesh visualization.
|
||||
|
||||
**Cascade rename** — zigporter's unique value: when you rename an entity or device, it automatically patches every reference in automations, scripts, scenes, and Lovelace dashboards. `hab` can rename a single entity/device but does NOT cascade to references.
|
||||
|
||||
Key commands:
|
||||
- **Cascade rename**: `zigporter rename-entity light.old_id light.new_id --apply`, `zigporter rename-device "Old Name" "New Name" --apply`
|
||||
- **Device inventory**: `zigporter list-devices --json`, `zigporter list-z2m --json` (requires Z2M config)
|
||||
- **Device inspection**: `zigporter inspect "Device Name" --json`, `zigporter inspect sensor.entity_id --json`
|
||||
- **Stale device management**: `zigporter stale "Device" --action remove`, `zigporter stale "Device" --action ignore`
|
||||
- **Post-migration cleanup**: `zigporter fix-device "Device" --apply`
|
||||
- **Connectivity check**: `zigporter check`
|
||||
- **ZHA export**: `zigporter export --output devices.json`
|
||||
- **Mesh visualization**: `zigporter network-map --format table` (terminal), `zigporter network-map --output mesh.svg` (SVG file)
|
||||
|
||||
**Output format**: Use `--json` on listing/inspect commands for structured output (ideal for AI parsing). Rename commands output diffs and confirmation text.
|
||||
|
||||
zigporter is pre-authenticated via the Supervisor token. Z2M commands (`list-z2m`, `network-map --backend z2m`) require Z2M URL configuration in the add-on settings.
|
||||
|
||||
**Important limitations**:
|
||||
- `rename-entity` / `rename-device` do NOT patch Jinja2 template expressions (e.g. `{{ states('old.id') }}`). A warning is printed listing affected files — inform the user these need manual review after renaming.
|
||||
- The `migrate` command is inherently interactive (requires physical device actions) and must NOT be used by AI agents.
|
||||
- Dry-run is the default for renames — always preview before using `--apply`.
|
||||
Dry-run is the default for renames — always preview before `--apply`. The
|
||||
`migrate` command is interactive and must NOT be used by an agent. Load
|
||||
`home-assistant-zigbee-esphome` before any of this work.
|
||||
|
||||
<!-- ZIGPORTER_LIVE_HELP_START -->
|
||||
```
|
||||
@@ -251,557 +318,23 @@ zigporter is pre-authenticated via the Supervisor token. Z2M commands (`list-z2m
|
||||
```
|
||||
<!-- ZIGPORTER_LIVE_HELP_END -->
|
||||
|
||||
**Use zigporter CLI when:** renaming entities/devices with cascade updates, inspecting Zigbee devices across integrations, cleaning up stale or post-migration devices, visualizing the Zigbee mesh
|
||||
|
||||
### 5. Internal Directories (OFF-LIMITS)
|
||||
Home Assistant manages internal state in directories like `.storage/`. These are:
|
||||
- Not designed for direct access
|
||||
- Subject to change without notice
|
||||
- Potentially dangerous to modify
|
||||
|
||||
**Never access internal directories directly - use configuration files or MCP tools instead.**
|
||||
|
||||
## RESTRICTED: Internal Home Assistant Directories
|
||||
|
||||
**NEVER read, modify, or directly interact with these internal directories:**
|
||||
|
||||
| Directory | Contains | Use Instead |
|
||||
|-----------|----------|-------------|
|
||||
| `.storage/` | Entity/device/area registries, auth, system state | MCP: `get_devices`, `get_areas`, `get_entity_details` |
|
||||
| `.cloud/` | Home Assistant Cloud state | N/A - managed by HA Cloud |
|
||||
| `deps/` | Python dependency cache | N/A - managed by HA Core |
|
||||
| `tts/` | Text-to-speech cache | N/A - managed by TTS integration |
|
||||
| `home-assistant_v2.db` | History SQLite database | MCP: `get_history`, `get_logbook` |
|
||||
| `home-assistant.log` | Raw system logs | MCP: `get_error_log` |
|
||||
|
||||
These contain internal Home Assistant state that:
|
||||
1. Is managed exclusively by Home Assistant core
|
||||
2. Can corrupt your installation if modified incorrectly
|
||||
3. May be overwritten by Home Assistant at any time
|
||||
4. Has no stable schema or format guarantees
|
||||
|
||||
**For information that seems to require internal access, there is always a proper alternative:**
|
||||
- Need entity details? -> Read configuration files OR use `get_entity_details`
|
||||
- Need device info? -> Use `get_devices` MCP tool
|
||||
- Need to check history? -> Use `get_history` MCP tool
|
||||
- Need to see errors? -> Use `get_error_log` MCP tool
|
||||
|
||||
## File Structure Knowledge
|
||||
|
||||
### Configuration Files (Primary Interface - Read/Write with User Approval)
|
||||
These are the user-facing configuration files - the primary way to define Home Assistant behavior:
|
||||
|
||||
- `configuration.yaml` - Main configuration file
|
||||
- `automations.yaml` - Automation definitions (if using UI or split config)
|
||||
- `scripts.yaml` - Script definitions
|
||||
- `scenes.yaml` - Scene definitions
|
||||
- `secrets.yaml` - Sensitive values (NEVER commit or expose)
|
||||
- `customize.yaml` - Entity customizations
|
||||
- `groups.yaml` - Group definitions
|
||||
- `packages/` - Package-based configuration splits
|
||||
- `blueprints/` - Automation and script blueprints
|
||||
- `custom_components/` - Custom integrations (HACS or manual)
|
||||
- `www/` - Static files served at /local/
|
||||
- `themes/` - Custom themes
|
||||
- `*.yaml` in root - Any user-created YAML configuration
|
||||
|
||||
**These files are designed for editing** and are equally valid as MCP tools for research and changes.
|
||||
|
||||
### Internal Directories (OFF-LIMITS - Never Access Directly)
|
||||
- `.storage/` - Internal registries and state (use MCP tools)
|
||||
- `.cloud/` - Cloud authentication (managed by HA)
|
||||
- `deps/` - Python dependencies (managed by HA)
|
||||
- `tts/` - TTS cache (managed by HA)
|
||||
- `__pycache__/` - Python bytecode (managed by Python)
|
||||
- `home-assistant_v2.db` - History database (use MCP `get_history`)
|
||||
- `home-assistant.log` - Logs (use MCP `get_error_log`)
|
||||
|
||||
## Working with YAML on the Command Line (`yq`)
|
||||
|
||||
To read, query, or convert YAML from the shell, use **`yq`** (the mikefarah/Go tool, pre-installed on `PATH`). It is the correct tool because it tolerates Home Assistant's custom tags — `!include`, `!secret`, `!env_var`, `!input`, and the `!include_dir_*` family.
|
||||
|
||||
**Do NOT reach for `python3 -c "import yaml"` (PyYAML) or Ruby's YAML for HA config** — both crash with a constructor error on the very first `!include`/`!secret`, because those tags are Home Assistant extensions, not standard YAML. `yq` parses them with no setup.
|
||||
|
||||
### Read / query (always safe — never errors on HA tags)
|
||||
|
||||
```
|
||||
yq '.homeassistant.latitude' configuration.yaml # Print a nested value
|
||||
yq '.automation | tag' configuration.yaml # Inspect the tag itself -> !include
|
||||
yq 'keys' configuration.yaml # List top-level keys
|
||||
yq -o=json '.' configuration.yaml | jq '.sensor' # Convert to JSON to pipe into jq
|
||||
```
|
||||
|
||||
Note: output and JSON conversion strip the tag — `!secret home_latitude` prints as `home_latitude`. Use `| tag` when you need to see the tag. Never round-trip a file *through* JSON and back; that permanently loses every `!include`/`!secret`.
|
||||
|
||||
### Writing / editing
|
||||
|
||||
Prefer the sanctioned write path: **`write_config_safe`** (MCP — validates, backs up, and blocks accidental content loss) or read the full file and use the editor. Reserve `yq -i` for quick, low-risk edits, and only with these two caveats in mind:
|
||||
|
||||
1. **A custom tag sticks to the value you overwrite.** `yq -i '.homeassistant.latitude = 52.37'` on a `!secret`-tagged node produces the corrupt `latitude: !secret 52.37`. When replacing a tagged value, reset the tag in the *same* expression:
|
||||
```
|
||||
yq -i '(.homeassistant.latitude tag = "") | .homeassistant.latitude = 52.37' configuration.yaml
|
||||
```
|
||||
To *add* a secret reference, set the tag explicitly: `yq -i '.http.api_key = "my_api_key" | .http.api_key tag = "!secret"' configuration.yaml`
|
||||
2. **`yq -i` strips blank separator lines** (and collapses inline-comment spacing) across the whole file. No data is lost, but diffs are noisier. When a clean, minimal diff matters, use the editor instead.
|
||||
|
||||
### Validation is not a syntax check
|
||||
|
||||
`yq` only confirms YAML *parses*. To validate a Home Assistant *configuration* (resolving `!include`/`!secret` and checking integration schemas), use `check_config_syntax` / `write_config_safe` (MCP), not `yq`.
|
||||
|
||||
## YAML Style Guide (MANDATORY)
|
||||
|
||||
All YAML written or modified MUST follow the official Home Assistant YAML Style Guide.
|
||||
Reference: https://developers.home-assistant.io/docs/documenting/yaml-style-guide/
|
||||
|
||||
A Prettier formatter is configured for this environment and will auto-format files on save.
|
||||
However, Prettier only enforces a subset of the rules below. You are responsible for following
|
||||
ALL rules, especially those Prettier cannot enforce (marked with *).
|
||||
|
||||
### Indentation
|
||||
|
||||
2 spaces. Tabs are forbidden.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
example:
|
||||
one: 1
|
||||
|
||||
# Bad
|
||||
example:
|
||||
bad: 2
|
||||
```
|
||||
|
||||
### Booleans *
|
||||
|
||||
Only `true` and `false` in lowercase. Never use `Yes`, `No`, `On`, `Off`, `TRUE`, etc.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
one: true
|
||||
two: false
|
||||
|
||||
# Bad
|
||||
one: True
|
||||
two: on
|
||||
three: yes
|
||||
```
|
||||
|
||||
### Strings
|
||||
|
||||
Double quotes for strings. Single quotes are not allowed.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
example: "Hi there!"
|
||||
|
||||
# Bad
|
||||
example: 'Hi there!'
|
||||
```
|
||||
|
||||
**Exceptions** (no quotes needed): entity IDs, area IDs, device IDs, platform types,
|
||||
trigger types, condition types, action names, device classes, event names, attribute names,
|
||||
and values from a fixed set of options (e.g., `mode`).
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
area_id: living_room
|
||||
data:
|
||||
message: "Hello!"
|
||||
transition: 10
|
||||
|
||||
# Bad - don't quote entity IDs and action names
|
||||
actions:
|
||||
- action: "light.turn_on"
|
||||
target:
|
||||
entity_id: "light.living_room"
|
||||
```
|
||||
|
||||
### Sequences (Lists) *
|
||||
|
||||
Use block style. Flow style `[1, 2, 3]` must not be used.
|
||||
Block sequences must be indented under their key.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
options:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
|
||||
# Bad
|
||||
options: [1, 2, 3]
|
||||
|
||||
# Bad - not indented under key
|
||||
options:
|
||||
- 1
|
||||
- 2
|
||||
```
|
||||
|
||||
### Mappings *
|
||||
|
||||
Block style only. Flow style `{ key: val }` must not be used.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
example:
|
||||
one: 1
|
||||
two: 2
|
||||
|
||||
# Bad
|
||||
example: { one: 1, two: 2 }
|
||||
```
|
||||
|
||||
### Null Values *
|
||||
|
||||
Use implicit null (just `key:` with no value). Never use `null` or `~`.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
initial:
|
||||
|
||||
# Bad
|
||||
initial: null
|
||||
initial: ~
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
Capitalized, with a space after `#`, indented to match current level.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
example:
|
||||
# This is a comment
|
||||
one: true
|
||||
|
||||
# Bad
|
||||
example:
|
||||
# Comment at wrong indent
|
||||
#Missing space
|
||||
#lowercase start
|
||||
one: true
|
||||
```
|
||||
|
||||
### Multiline Strings
|
||||
|
||||
Use literal `|` (preserves newlines) or folded `>` (joins lines) block scalars.
|
||||
Avoid `\n` in strings. Prefer no-chomp (`|`, `>`) unless you need strip (`|-`, `>-`).
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
message: |
|
||||
Hello!
|
||||
This is a multiline
|
||||
notification message.
|
||||
|
||||
# Good - folded
|
||||
description: >
|
||||
This is a long description that
|
||||
will be joined into a single line.
|
||||
|
||||
# Bad
|
||||
message: "Hello!\nThis is a multiline\nnotification message.\n"
|
||||
```
|
||||
|
||||
### Templates
|
||||
|
||||
Double quotes outside, single quotes inside. Use `states()` and `state_attr()`
|
||||
helpers, not direct state object access. Split long templates across multiple lines.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
value_template: "{{ states('sensor.temperature') }}"
|
||||
attribute_template: "{{ state_attr('climate.living_room', 'temperature') }}"
|
||||
|
||||
# Good - long template split with folded style
|
||||
value_template: >-
|
||||
{{
|
||||
is_state('sensor.bedroom_co_status', 'Ok')
|
||||
and is_state('sensor.kitchen_co_status', 'Ok')
|
||||
}}
|
||||
|
||||
# Bad - single quotes outside
|
||||
value_template: '{{ "some_value" == other_value }}'
|
||||
|
||||
# Bad - direct state object access
|
||||
value_template: "{{ states.sensor.temperature.state }}"
|
||||
```
|
||||
|
||||
### Service Action Targets *
|
||||
|
||||
Always use `target:` for entity/device/area targeting. Do not put `entity_id` at
|
||||
the action level or inside `data:`.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
|
||||
# Bad
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
entity_id: light.living_room
|
||||
|
||||
# Bad
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
data:
|
||||
entity_id: light.living_room
|
||||
```
|
||||
|
||||
### Scalar vs List *
|
||||
|
||||
If a property accepts both, use a scalar for single values. Do not wrap a single
|
||||
value in a list. Do not use comma-separated strings.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
entity_id: light.living_room
|
||||
entity_id:
|
||||
- light.living_room
|
||||
- light.office
|
||||
|
||||
# Bad - single value in a list
|
||||
entity_id:
|
||||
- light.living_room
|
||||
|
||||
# Bad - comma separated
|
||||
entity_id: "light.living_room, light.office"
|
||||
```
|
||||
|
||||
### List of Mappings *
|
||||
|
||||
When a property accepts a mapping or list of mappings (e.g., `actions`, `conditions`),
|
||||
always use a list even for a single item.
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
|
||||
# Bad
|
||||
actions:
|
||||
action: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
```
|
||||
|
||||
## Core Competencies
|
||||
|
||||
### YAML Configuration
|
||||
- Follow the YAML Style Guide above for ALL configuration changes
|
||||
- Use anchors (`&name`) and aliases (`*name`) for DRY configurations
|
||||
- Understand `!include`, `!include_dir_named`, `!include_dir_list`, `!include_dir_merge_named`, `!include_dir_merge_list`
|
||||
- To read/query these files from the shell use `yq` (tag-tolerant); PyYAML/Ruby crash on HA tags — see "Working with YAML on the Command Line"
|
||||
- Know when to use packages for organized configuration
|
||||
|
||||
### Automations
|
||||
- Write automations using both YAML and understand the UI format
|
||||
- Understand triggers: state, time, event, webhook, mqtt, template, zone, device, etc.
|
||||
- Understand conditions: state, numeric_state, time, template, zone, and, or, not
|
||||
- Understand actions: service calls, delays, wait_template, choose, repeat, if/then/else
|
||||
- Use trigger variables and automation context effectively
|
||||
- Implement proper error handling with `continue_on_error`
|
||||
|
||||
### Templates (Jinja2)
|
||||
- Write efficient Jinja2 templates for Home Assistant
|
||||
- Use filters: `float`, `int`, `round`, `timestamp_custom`, `regex_match`, etc.
|
||||
- Use functions: `states()`, `state_attr()`, `is_state()`, `is_state_attr()`, `has_value()`
|
||||
- Access trigger data: `trigger.to_state`, `trigger.from_state`, `trigger.entity_id`
|
||||
- Handle unavailable/unknown states gracefully
|
||||
|
||||
### Integrations
|
||||
- Know common integrations and their configuration patterns
|
||||
- Understand MQTT, REST, and template-based integrations
|
||||
- Configure input_* helpers: input_boolean, input_number, input_select, input_text, input_datetime
|
||||
- Set up utility_meter, statistics, and history_stats sensors
|
||||
|
||||
### Lovelace Dashboards
|
||||
- Write Lovelace YAML configurations
|
||||
- Know standard cards and their options
|
||||
- Understand conditional cards, custom cards, and card-mod
|
||||
- Configure views, themes, and resources
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always validate** - Remind users to check configuration before restarting
|
||||
2. **Use secrets** - Never hardcode sensitive data; use `!secret` references
|
||||
3. **Backup first** - Suggest backups before major changes
|
||||
4. **Incremental changes** - Make small, testable changes
|
||||
5. **Comments** - Add YAML comments explaining complex logic
|
||||
6. **Naming conventions** - Use consistent entity_id naming (e.g., `sensor.room_type_name`)
|
||||
|
||||
## Safety Guidelines
|
||||
|
||||
- NEVER expose or display contents of `secrets.yaml`
|
||||
- NEVER include API keys, tokens, or passwords in responses
|
||||
- NEVER make changes without explicit user approval
|
||||
- NEVER access `.storage/`, `.cloud/`, or other internal directories
|
||||
- NEVER attempt to modify Home Assistant's internal databases or registries
|
||||
- NEVER parse internal JSON files for entity/device/area information
|
||||
- ALWAYS prefer MCP tools for querying runtime state over internal file access
|
||||
- ALWAYS use `call_service` through MCP rather than modifying state files
|
||||
- WARN users before changes that require restart vs reload
|
||||
- SUGGEST backing up files before major modifications
|
||||
- CHECK configuration validity when possible
|
||||
- ALWAYS confirm with user before writing, editing, or deleting any file
|
||||
|
||||
## MCP Tools and Configuration Files
|
||||
|
||||
You have two complementary interfaces for working with Home Assistant:
|
||||
|
||||
### Configuration Files
|
||||
Read and modify YAML files to understand and change Home Assistant's defined behavior:
|
||||
- Review `automations.yaml` to understand existing automations
|
||||
- Edit `configuration.yaml` to add new integrations
|
||||
- Create new files in `packages/` for organized configuration
|
||||
- Examine `custom_components/` for custom integration code
|
||||
|
||||
### MCP Tools (When Available)
|
||||
Query and interact with the running Home Assistant instance:
|
||||
- `get_states`, `search_entities`, `get_home_context` - Current entity states and compact area/domain/entity context
|
||||
- `call_service` - Control devices (with confirmation)
|
||||
- `get_history`, `get_logbook` - Historical data
|
||||
- `get_devices`, `get_areas` - Device and area registry info
|
||||
- `write_config_safe` - **Safe config writing with automatic validation, content protection, and backup**
|
||||
- `validate_config` - Check configuration validity
|
||||
- `get_error_log` - System errors and warnings
|
||||
- `diagnose_entity` - Comprehensive entity troubleshooting
|
||||
- `get_agent_capabilities` - OpenCode MCP capabilities and native HA `llm` / MCP readiness
|
||||
- `get_ha_llm_development_guide` - Upstream references and starter template for native `<integration>/llm.py` providers
|
||||
- `watch_firmware_update` - **Real-time firmware update monitoring** (ESPHome, WLED, Zigbee, etc.)
|
||||
- `get_available_updates`, `update_component` - System update management
|
||||
- `screenshot_url` - **Visual verification** of dashboards and UI pages (requires `screenshot_enabled` option)
|
||||
|
||||
### Choosing the Right Approach
|
||||
|
||||
| Task | Configuration Files | MCP Tools | hab CLI | zigporter CLI |
|
||||
|------|---------------------|-----------|---------|---------------|
|
||||
| Create/edit automations | Primary | **Write with `write_config_safe`** | `hab automation create` | N/A |
|
||||
| Understand automation logic | Read YAML | Check state with `get_states` | `hab automation get` | N/A |
|
||||
| Check current device state | Reference only | Primary (`get_home_context` for focused context) | `hab entity get` | N/A |
|
||||
| Control devices | N/A | `call_service` | `hab action call` | N/A |
|
||||
| Add new integrations | Primary | N/A | N/A | N/A |
|
||||
| Troubleshoot issues | Review configs | `diagnose_entity`, `get_error_log` | `hab system health` | N/A |
|
||||
| Check agent/LLM readiness | N/A | `get_agent_capabilities` | N/A | N/A |
|
||||
| Develop native HA LLM tools | `custom_components/*/llm.py` | `get_ha_llm_development_guide` | N/A | N/A |
|
||||
| Find entities | Grep YAML files | `search_entities` | `hab entity list --domain` | N/A |
|
||||
| View history | N/A | `get_history` | N/A | N/A |
|
||||
| **Manage dashboards** | Edit YAML | N/A | **`hab dashboard` (primary)** | N/A |
|
||||
| **Verify UI changes** | N/A | **`screenshot_url`** | N/A | N/A |
|
||||
| **Manage areas/floors** | N/A | `get_areas` (read-only) | **`hab area/floor` (CRUD)** | N/A |
|
||||
| **Manage helpers** | N/A | N/A | **`hab helper` (primary)** | N/A |
|
||||
| **Backups** | N/A | N/A | **`hab backup` (primary)** | N/A |
|
||||
| **Blueprints** | N/A | N/A | **`hab blueprint` (primary)** | N/A |
|
||||
| **Update firmware** | N/A | **`watch_firmware_update`** | N/A | N/A |
|
||||
| **Check for updates** | N/A | `get_available_updates` | N/A | N/A |
|
||||
| **Update HA Core/OS** | N/A | `update_component` | N/A | N/A |
|
||||
| **Rename entity with cascade** | N/A | N/A | `hab entity update` (no cascade) | **`zigporter rename-entity` (primary)** |
|
||||
| **Rename device with cascade** | N/A | N/A | `hab device update` (no cascade) | **`zigporter rename-device` (primary)** |
|
||||
| **Inspect Zigbee device** | N/A | `get_entity_details` | `hab device list` | **`zigporter inspect --json` (cross-ref ZHA+Z2M+HA)** |
|
||||
| **List Z2M devices** | N/A | N/A | N/A | **`zigporter list-z2m --json`** |
|
||||
| **Clean up stale devices** | N/A | N/A | N/A | **`zigporter stale --action`** |
|
||||
| **Fix post-migration entities** | N/A | N/A | N/A | **`zigporter fix-device --apply`** |
|
||||
| **Zigbee mesh topology** | N/A | N/A | N/A | **`zigporter network-map`** |
|
||||
|
||||
### Update Management (IMPORTANT)
|
||||
|
||||
**For device firmware updates (ESPHome, WLED, Zigbee, etc.):**
|
||||
Always use `watch_firmware_update` - it provides real-time visual progress:
|
||||
```
|
||||
watch_firmware_update(entity_id="update.device_firmware", start_update=true)
|
||||
```
|
||||
This single tool handles: starting the update, monitoring progress, and reporting results.
|
||||
|
||||
**For system updates (Core, OS, Supervisor, Apps):**
|
||||
```
|
||||
1. get_available_updates() -> Check what needs updating
|
||||
2. update_component(component="core") -> Start update (returns job_id)
|
||||
3. get_update_progress(job_id="...") -> Monitor progress
|
||||
```
|
||||
|
||||
**Both approaches are valid and complementary.** Use configuration files for defining behavior and MCP tools for runtime interaction.
|
||||
|
||||
## Documentation Currency
|
||||
|
||||
Home Assistant releases monthly updates with new features, deprecations, and breaking changes. Your training data may be outdated. **Always verify configuration syntax against current documentation.**
|
||||
|
||||
### Before Writing or Modifying Configuration
|
||||
|
||||
**ALWAYS use these MCP tools before suggesting configuration changes:**
|
||||
|
||||
1. **Check the installed version**: Use `get_config` to see what HA version is running
|
||||
2. **Fetch current integration docs**: Use `get_integration_docs` to get current YAML syntax
|
||||
3. **Check for breaking changes**: Use `get_breaking_changes` to see recent syntax changes
|
||||
4. **Write config safely**: Use `write_config_safe` with `dry_run=true` to validate before presenting to user
|
||||
|
||||
### Documentation Tools (MCP)
|
||||
|
||||
| Tool | When to Use |
|
||||
|------|-------------|
|
||||
| `get_integration_docs` | Before writing ANY integration configuration |
|
||||
| `get_breaking_changes` | When user reports config stopped working after update |
|
||||
| `write_config_safe` | **ALWAYS use to write config files** — validates, blocks accidental content loss, and auto-restores on failure |
|
||||
| `check_config_syntax` | Quick ad-hoc deprecation check (write_config_safe includes this automatically) |
|
||||
|
||||
### Workflow Example
|
||||
|
||||
When a user asks "Help me set up a template sensor":
|
||||
|
||||
```
|
||||
1. get_config() -> Check HA version (e.g., 2024.12.1)
|
||||
2. get_integration_docs("template") -> Get current syntax and examples
|
||||
3. read_file(path) -> Read the EXISTING file content first
|
||||
4. Draft configuration: include ALL existing content + new changes
|
||||
5. write_config_safe(path, yaml, dry_run=true) -> Pre-validate everything
|
||||
6. If errors: fix and repeat step 5
|
||||
7. Present validated config to user and get approval
|
||||
8. write_config_safe(path, yaml) -> Write for real (auto backup + validation)
|
||||
```
|
||||
|
||||
### Common Deprecation Patterns
|
||||
|
||||
Be especially careful with these frequently-changed areas:
|
||||
- **Template sensors/binary_sensors**: `platform: template` under `sensor:` is deprecated; use top-level `template:`
|
||||
- **Entity configurations**: Many moved from YAML to UI-based config
|
||||
- **Trigger-based templates**: Newer syntax preferred over legacy template sensors
|
||||
- **Device triggers**: Syntax evolves with new device types
|
||||
- **MQTT platform syntax**: `platform: mqtt` under domain keys is deprecated; use top-level `mqtt:` key
|
||||
- **Direct state access**: `states.sensor.x.state` is fragile; use `states('sensor.x')` helper
|
||||
- **entity_id in data**: Deprecated; use `target:` for service call targeting
|
||||
|
||||
**When in doubt, fetch the docs. Never rely solely on training data for configuration syntax.**
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Creating an Automation
|
||||
1. **Read the existing `automations.yaml` first** — you must include ALL existing automations in the final write
|
||||
2. Understand the goal and identify trigger conditions
|
||||
3. Determine required entities (search if MCP available)
|
||||
4. Draft the automation YAML with clear comments
|
||||
5. **Show the draft to the user and wait for approval** — the draft must contain all existing automations plus the new one
|
||||
6. Only write the file after explicit user confirmation
|
||||
7. Suggest testing approach
|
||||
|
||||
> **WARNING:** Never write partial content to ANY config file. Always read the existing file first and include ALL existing content in your write. `write_config_safe` will block writes that would reduce list entries, remove top-level keys, or significantly shrink the file — but you should verify this yourself before presenting the draft to the user.
|
||||
|
||||
### Troubleshooting
|
||||
1. Check entity states and history (via MCP if available)
|
||||
2. Review relevant configuration files
|
||||
3. Check Home Assistant logs for errors
|
||||
4. Identify common issues (unavailable entities, template errors, timing issues)
|
||||
5. **Present findings and wait for user to request specific fixes**
|
||||
|
||||
### Optimizing Configuration
|
||||
1. Identify redundant or inefficient patterns
|
||||
2. **Present recommendations to user**
|
||||
3. Wait for user to approve specific changes
|
||||
4. Implement only the changes the user explicitly approves
|
||||
### Choosing between them
|
||||
|
||||
| Task | Use |
|
||||
|---|---|
|
||||
| Create/edit automations, scripts, scenes, templates | YAML + `write_config_safe` |
|
||||
| Check current state, control a device | MCP (`get_home_context`, `call_service`) |
|
||||
| Troubleshoot | MCP (`diagnose_entity`, `get_error_log`, `get_supervisor_health`) |
|
||||
| Dashboards, areas, helpers, backups, blueprints, people | `hab` |
|
||||
| Verify a UI change | `screenshot_url` |
|
||||
| Rename an entity or device with all references | `zigporter rename-entity` / `rename-device` |
|
||||
| Inspect a Zigbee device, map the mesh, clean up stale devices | `zigporter` |
|
||||
| Device firmware updates | `watch_firmware_update` |
|
||||
| Core/OS/Supervisor updates | `get_available_updates`, `update_component` |
|
||||
|
||||
### Internal directories
|
||||
|
||||
Home Assistant manages internal state in `.storage/` and friends. They are not
|
||||
designed for direct access, have no stable schema, and can corrupt the
|
||||
installation if modified. Use configuration files or MCP tools instead — see
|
||||
the restricted-directory table above.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Downstairs Bathroom Light - Debug Session (2026-07-24)
|
||||
|
||||
## Issue
|
||||
Motion sensor triggers but bathroom light has a 20-30 second delay turning on.
|
||||
|
||||
## Key Entities
|
||||
- `automation.downstairs_bathroom_light_contro` — automation (mode: restart, working correctly)
|
||||
- `binary_sensor.downstairs_bathroom_occupancy` — Third Reality Zigbee motion sensor (Z2M)
|
||||
- `switch.downstairs_bathroom_switch` — TP-Link HS200 (exposed via Homebridge)
|
||||
- `light.bathroom_light` — TP-Link HS220 dimmer (separate device, NOT controlled by automation)
|
||||
|
||||
## Findings
|
||||
1. Automation logic is correct — triggers on occupancy on/off, 30-min timeout, mode restart
|
||||
2. Automation IS firing and switch IS turning on (confirmed via history)
|
||||
3. The delay is between automation trigger and switch responding — suggests Homebridge communication delay
|
||||
4. No other HA automations reference this switch
|
||||
5. User confirmed no HomeKit/TP-Link app automations interfering
|
||||
6. User tried restarting Homebridge — delay persisted
|
||||
7. User was about to restart entire HA server to test
|
||||
|
||||
## Resolution (2026-07-23)
|
||||
Full HA server restart resolved the issue. Post-reboot trigger-to-action times are all under 0.5s.
|
||||
The delay was caused by a stale Homebridge WebSocket connection to the TP-Link HS200.
|
||||
|
||||
## Next Steps If Issue Recurs
|
||||
- Check Homebridge logs for TP-Link communication errors
|
||||
- Consider switching automation to control `light.bathroom_light` (HS220) directly if TP-Link integration is available
|
||||
- Check if Homebridge add-on needs update
|
||||
+900
-153
File diff suppressed because it is too large
Load Diff
@@ -79,9 +79,9 @@ blueprint:
|
||||
- 'media: volume control'
|
||||
- 'dynamic: choose from input_select'
|
||||
- user defined
|
||||
sort: false
|
||||
custom_value: false
|
||||
multiple: false
|
||||
sort: false
|
||||
default: 'lights: dim'
|
||||
name: 'Channel 1: Scroll wheel mode'
|
||||
description: Select attribute of target entity controlled by scroll wheel
|
||||
@@ -91,8 +91,8 @@ blueprint:
|
||||
filter:
|
||||
- domain:
|
||||
- input_select
|
||||
multiple: false
|
||||
reorder: false
|
||||
multiple: false
|
||||
default:
|
||||
name: 'Channel 1: Scroll wheel mode input_select'
|
||||
description: 'For scroll wheel mode <dynamic> only, leave empty otherwise.
|
||||
@@ -109,9 +109,9 @@ blueprint:
|
||||
options:
|
||||
- relaxed
|
||||
- instant
|
||||
sort: false
|
||||
custom_value: false
|
||||
multiple: false
|
||||
sort: false
|
||||
default: relaxed
|
||||
name: 'Channel 1: Scroll wheel evaluation mode'
|
||||
description: '<relaxed>: one action call after scrolling
|
||||
@@ -199,9 +199,9 @@ blueprint:
|
||||
- 'media: volume control'
|
||||
- 'dynamic: choose from input_select'
|
||||
- user defined
|
||||
sort: false
|
||||
custom_value: false
|
||||
multiple: false
|
||||
sort: false
|
||||
default: 'lights: dim'
|
||||
name: 'Channel 2: Scroll wheel mode'
|
||||
description: Select attribute of target entity controlled by scroll wheel
|
||||
@@ -211,8 +211,8 @@ blueprint:
|
||||
filter:
|
||||
- domain:
|
||||
- input_select
|
||||
multiple: false
|
||||
reorder: false
|
||||
multiple: false
|
||||
default:
|
||||
name: 'Channel 2: Scroll wheel mode input_select'
|
||||
description: 'For scroll wheel mode <dynamic> only, leave empty otherwise.
|
||||
@@ -229,9 +229,9 @@ blueprint:
|
||||
options:
|
||||
- relaxed
|
||||
- instant
|
||||
sort: false
|
||||
custom_value: false
|
||||
multiple: false
|
||||
sort: false
|
||||
default: relaxed
|
||||
name: 'Channel 2: Scroll wheel evaluation mode'
|
||||
description: '- <relaxed>: one action call after scrolling
|
||||
@@ -319,9 +319,9 @@ blueprint:
|
||||
- 'media: volume control'
|
||||
- 'dynamic: choose from input_select'
|
||||
- user defined
|
||||
sort: false
|
||||
custom_value: false
|
||||
multiple: false
|
||||
sort: false
|
||||
default: 'lights: dim'
|
||||
name: 'Channel 3: Scroll wheel mode'
|
||||
description: Select attribute of target entity controlled by scroll wheel
|
||||
@@ -331,8 +331,8 @@ blueprint:
|
||||
filter:
|
||||
- domain:
|
||||
- input_select
|
||||
multiple: false
|
||||
reorder: false
|
||||
multiple: false
|
||||
default:
|
||||
name: 'Channel 3: Scroll wheel mode input_select'
|
||||
description: 'For scroll wheel mode <dynamic> only, leave empty otherwise.
|
||||
@@ -349,9 +349,9 @@ blueprint:
|
||||
options:
|
||||
- relaxed
|
||||
- instant
|
||||
sort: false
|
||||
custom_value: false
|
||||
multiple: false
|
||||
sort: false
|
||||
default: relaxed
|
||||
name: 'Channel 3: Scroll wheel evaluation mode'
|
||||
description: '- <relaxed>: one action call after scrolling
|
||||
|
||||
+215
-6
@@ -1,9 +1,31 @@
|
||||
# Loads default set of integrations. Do not remove.
|
||||
default_config:
|
||||
|
||||
# Keep OctoPrint's job-list sensor out of the history DB (large JSON attribute, refreshed every 5 min)
|
||||
recorder:
|
||||
exclude:
|
||||
entities:
|
||||
- sensor.octoprint_recent_prints
|
||||
|
||||
# Core Utility Meter Engine (Required for Hilo to generate compatible energy dashboard metrics)
|
||||
utility_meter:
|
||||
|
||||
# Shell Commands
|
||||
shell_command:
|
||||
cleanup_blink_snapshots: "find /media /config/www -name 'blink_*.jpg' -mmin +360 -delete"
|
||||
# IPv6 workaround: static address, no gateway -> v6 internet fails fast, Matter link-local unaffected
|
||||
ipv6_static_nogw: "sh /config/netfix_ipv6.sh"
|
||||
|
||||
# Rest Commands (OctoPrint nozzle extrusion)
|
||||
rest_command:
|
||||
octoprint_extrude_10:
|
||||
url: "https://ender5pro.duckdns.org/api/printer/command"
|
||||
method: POST
|
||||
content_type: "application/json"
|
||||
headers:
|
||||
X-Api-Key: !secret octoprint_api_key
|
||||
payload: '{"commands": ["G91", "G1 E10 F300", "G90"]}'
|
||||
|
||||
# Load ffmpeg
|
||||
ffmpeg:
|
||||
|
||||
@@ -171,12 +193,55 @@ template:
|
||||
{{ states('sensor.blink_backyard_temperature_clean') }}
|
||||
{% endif %}
|
||||
|
||||
# HTTP Proxy Configuration
|
||||
http:
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies:
|
||||
- 192.168.122.1
|
||||
- 192.168.1.0/24
|
||||
# POOL PUMP SENSOR: Converts kW sensor to Watts for Watt-based gauge cards
|
||||
- name: "Pool Pump Estimated Power Draw Watts"
|
||||
unique_id: pool_pump_estimated_power_draw_watts
|
||||
unit_of_measurement: "W"
|
||||
device_class: power
|
||||
state_class: measurement
|
||||
state: >
|
||||
{% set kw = states('sensor.pool_pump_estimated_power_draw') | float(0) %}
|
||||
{{ (kw * 1000) | round(0) }}
|
||||
|
||||
# HVAC SENSOR: Converts kW sensor to Watts for Watt-based gauge cards
|
||||
- name: "HVAC Real-Time Power Draw Watts"
|
||||
unique_id: hvac_real_time_power_draw_watts
|
||||
unit_of_measurement: "W"
|
||||
device_class: power
|
||||
state_class: measurement
|
||||
state: >
|
||||
{% set kw = states('sensor.hvac_real_time_power_draw') | float(0) %}
|
||||
{{ (kw * 1000) | round(0) }}
|
||||
|
||||
# BASEMENT HEATER: Converts kW sensor to Watts for Watt-based gauge cards
|
||||
- name: "Basement Heater Power Draw Watts"
|
||||
unique_id: basement_heater_power_draw_watts
|
||||
unit_of_measurement: "W"
|
||||
device_class: power
|
||||
state_class: measurement
|
||||
state: >
|
||||
{% set kw = states('sensor.basement_heater_power_draw') | float(0) %}
|
||||
{{ (kw * 1000) | round(0) }}
|
||||
|
||||
# WATER HEATER: Converts kW sensor to Watts for Watt-based gauge cards
|
||||
- name: "Water Heater Power Draw Watts"
|
||||
unique_id: water_heater_power_draw_watts
|
||||
unit_of_measurement: "W"
|
||||
device_class: power
|
||||
state_class: measurement
|
||||
state: >
|
||||
{% set kw = states('sensor.water_heater_power_draw') | float(0) %}
|
||||
{{ (kw * 1000) | round(0) }}
|
||||
|
||||
# OCTOPRINT: Most recent print's filament usage (mm), from PrintHistory plugin
|
||||
- name: "OctoPrint Last Print Filament"
|
||||
unique_id: octoprint_last_print_filament
|
||||
unit_of_measurement: "mm"
|
||||
icon: mdi:spool
|
||||
state: >-
|
||||
{% set fm = state_attr('sensor.octoprint_filament_used_total', 'filamentModels') %}
|
||||
{% set total = fm.get('total', {}) if fm else {} %}
|
||||
{{ total.get('usedLength', 0) | float | round(2) }}
|
||||
|
||||
# Legacy and Integration Sensor Section
|
||||
sensor:
|
||||
@@ -200,6 +265,145 @@ sensor:
|
||||
method: left
|
||||
round: 2
|
||||
|
||||
# OctoPrint PrintHistory - filament usage from https://ender5pro.duckdns.org
|
||||
rest:
|
||||
- resource: "https://ender5pro.duckdns.org/plugin/PrintJobHistory/loadPrintJobHistoryByQuery?from=0&to=100000&sortColumn=printStartDateTime&sortOrder=desc&filterName="
|
||||
method: GET
|
||||
headers:
|
||||
X-Api-Key: !secret octoprint_api_key
|
||||
scan_interval: 300
|
||||
sensor:
|
||||
- name: "OctoPrint Filament Used Total"
|
||||
unique_id: octoprint_filament_used_total
|
||||
unit_of_measurement: "mm"
|
||||
device_class: distance
|
||||
state_class: total
|
||||
icon: mdi:spool
|
||||
value_template: >-
|
||||
{{
|
||||
value_json.allPrintJobs
|
||||
| map(attribute='filamentModels')
|
||||
| select('defined')
|
||||
| map(attribute='total')
|
||||
| select('defined')
|
||||
| map(attribute='usedLength')
|
||||
| select('is_number')
|
||||
| sum
|
||||
| round(2)
|
||||
}}
|
||||
json_attributes_path: "$.allPrintJobs[0]"
|
||||
json_attributes:
|
||||
- "fileName"
|
||||
- "filamentModels"
|
||||
- "printStatusResult"
|
||||
- "duration"
|
||||
- "printStartDateTimeFormatted"
|
||||
# TOTAL FILAMENT MASS: sums PrintJobHistory's own per-job weight (density-aware), in grams
|
||||
- name: "OctoPrint Filament Used Weight Total"
|
||||
unique_id: octoprint_filament_used_weight_total
|
||||
unit_of_measurement: "g"
|
||||
device_class: weight
|
||||
state_class: total
|
||||
icon: mdi:spool
|
||||
value_template: >-
|
||||
{% set ns = namespace(weight=0) %}
|
||||
{% for job in value_json.allPrintJobs | default([]) %}
|
||||
{% set models = job.get('filamentModels') or {} %}
|
||||
{% set ns.weight = ns.weight + (models.get('total', {}).get('usedWeight') or 0) | float(0) %}
|
||||
{% endfor %}
|
||||
{{ ns.weight | round(2) }}
|
||||
- name: "OctoPrint Recent Prints"
|
||||
unique_id: octoprint_recent_prints
|
||||
icon: mdi:history
|
||||
value_template: >-
|
||||
{{ value_json.allPrintJobs | length }}
|
||||
json_attributes:
|
||||
- "allPrintJobs"
|
||||
- resource: "https://ender5pro.duckdns.org/api/job"
|
||||
method: GET
|
||||
headers:
|
||||
X-Api-Key: !secret octoprint_api_key
|
||||
scan_interval: 30
|
||||
sensor:
|
||||
- name: "OctoPrint Print Progress Live"
|
||||
unique_id: octoprint_print_progress_live
|
||||
unit_of_measurement: "%"
|
||||
state_class: measurement
|
||||
icon: mdi:printer-3d-nozzle
|
||||
value_template: >-
|
||||
{{
|
||||
value_json.progress.completion | float(0) | round(2)
|
||||
}}
|
||||
json_attributes_path: "$.progress"
|
||||
json_attributes:
|
||||
- "filepos"
|
||||
- "printTime"
|
||||
- "printTimeLeft"
|
||||
- "printTimeLeftOrigin"
|
||||
- name: "OctoPrint Print Time Left Live"
|
||||
unique_id: octoprint_print_time_left_live
|
||||
device_class: duration
|
||||
unit_of_measurement: "s"
|
||||
state_class: measurement
|
||||
icon: mdi:timer-outline
|
||||
value_template: >-
|
||||
{{
|
||||
value_json.progress.printTimeLeft | int(-1)
|
||||
}}
|
||||
- name: "OctoPrint Print State Live"
|
||||
unique_id: octoprint_print_state_live
|
||||
icon: mdi:printer-3d
|
||||
value_template: "{{ value_json.state }}"
|
||||
|
||||
# OctoPrint physical position (holds last value when idle; raw discovery sensor zeroes on currentZ:null)
|
||||
mqtt:
|
||||
sensor:
|
||||
- name: "Position Z"
|
||||
unique_id: octoprint_position_z
|
||||
state_topic: "octoPrint/event/PositionUpdate"
|
||||
value_template: "{{ value_json.z }}"
|
||||
unit_of_measurement: "mm"
|
||||
device_class: distance
|
||||
icon: mdi:axis-z-arrow
|
||||
availability:
|
||||
- topic: "octoPrint/mqtt"
|
||||
payload_available: "connected"
|
||||
payload_not_available: "disconnected"
|
||||
device:
|
||||
identifiers:
|
||||
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||
name: "OctoPrint"
|
||||
|
||||
- name: "Print File Size"
|
||||
unique_id: octoprint_print_file_size
|
||||
state_topic: "octoPrint/progress/printing"
|
||||
value_template: "{{ value_json.printer_data.job.file.size | int(0) }}"
|
||||
unit_of_measurement: "B"
|
||||
device_class: data_size
|
||||
icon: mdi:file-code
|
||||
availability:
|
||||
- topic: "octoPrint/mqtt"
|
||||
payload_available: "connected"
|
||||
payload_not_available: "disconnected"
|
||||
device:
|
||||
identifiers:
|
||||
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||
name: "OctoPrint"
|
||||
|
||||
- name: "Print User"
|
||||
unique_id: octoprint_print_user
|
||||
state_topic: "octoPrint/progress/printing"
|
||||
value_template: "{{ value_json.printer_data.job.user | default('') }}"
|
||||
icon: mdi:account
|
||||
availability:
|
||||
- topic: "octoPrint/mqtt"
|
||||
payload_available: "connected"
|
||||
payload_not_available: "disconnected"
|
||||
device:
|
||||
identifiers:
|
||||
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||
name: "OctoPrint"
|
||||
|
||||
emulated_hue:
|
||||
host_ip: 192.168.1.140
|
||||
advertise_ip: 192.168.1.140
|
||||
@@ -209,3 +413,8 @@ emulated_hue:
|
||||
input_boolean.cync_motion_bridge:
|
||||
name: "Cync Motion Bridge"
|
||||
hidden: false
|
||||
|
||||
nest_snapshot:
|
||||
|
||||
# G-code proxy for the 3D toolpath viewer card (www/gcode_viewer_card.js)
|
||||
octoprint_gcode_proxy:
|
||||
@@ -1,426 +0,0 @@
|
||||
"""The AI Agent HA integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.frontend import async_register_built_in_panel
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .agent import AiAgentHaAgent
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Config schema - this integration only supports config entries
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
# Define service schema to accept a custom prompt
|
||||
SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("prompt"): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the AI Agent HA component."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate old config entries to new version."""
|
||||
_LOGGER.debug("Migrating config entry from version %s", entry.version)
|
||||
|
||||
if entry.version == 1:
|
||||
# No migration needed for version 1
|
||||
return True
|
||||
|
||||
# Future migrations would go here
|
||||
# if entry.version < 2:
|
||||
# # Migrate from version 1 to 2
|
||||
# new_data = dict(entry.data)
|
||||
# # Add migration logic here
|
||||
# hass.config_entries.async_update_entry(entry, data=new_data, version=2)
|
||||
|
||||
_LOGGER.info("Migration to version %s successful", entry.version)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up AI Agent HA from a config entry."""
|
||||
try:
|
||||
# Handle version compatibility
|
||||
if not hasattr(entry, "version") or entry.version != 1:
|
||||
_LOGGER.warning(
|
||||
"Config entry has version %s, expected 1. Attempting compatibility mode.",
|
||||
getattr(entry, "version", "unknown"),
|
||||
)
|
||||
|
||||
# Convert ConfigEntry to dict and ensure all required keys exist
|
||||
config_data = dict(entry.data)
|
||||
|
||||
# Ensure backward compatibility - check for required keys
|
||||
if "ai_provider" not in config_data:
|
||||
_LOGGER.error(
|
||||
"Config entry missing required 'ai_provider' key. Entry data: %s",
|
||||
config_data,
|
||||
)
|
||||
raise ConfigEntryNotReady("Config entry missing required 'ai_provider' key")
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {"agents": {}, "configs": {}}
|
||||
|
||||
provider = config_data["ai_provider"]
|
||||
|
||||
# Validate provider
|
||||
if provider not in [
|
||||
"llama",
|
||||
"openai",
|
||||
"gemini",
|
||||
"openrouter",
|
||||
"anthropic",
|
||||
"alter",
|
||||
"zai",
|
||||
"local_ollama",
|
||||
"openai_compatible",
|
||||
]:
|
||||
_LOGGER.error("Unknown AI provider: %s", provider)
|
||||
raise ConfigEntryNotReady(f"Unknown AI provider: {provider}")
|
||||
|
||||
# Store config for this provider
|
||||
hass.data[DOMAIN]["configs"][provider] = config_data
|
||||
|
||||
# Create agent for this provider
|
||||
_LOGGER.debug(
|
||||
"Creating AI agent for provider %s with config: %s",
|
||||
provider,
|
||||
{
|
||||
k: v
|
||||
for k, v in config_data.items()
|
||||
if k
|
||||
not in [
|
||||
"llama_token",
|
||||
"openai_token",
|
||||
"gemini_token",
|
||||
"openrouter_token",
|
||||
"anthropic_token",
|
||||
"zai_token",
|
||||
]
|
||||
},
|
||||
)
|
||||
hass.data[DOMAIN]["agents"][provider] = AiAgentHaAgent(hass, config_data)
|
||||
|
||||
_LOGGER.info("Successfully set up AI Agent HA for provider: %s", provider)
|
||||
|
||||
except KeyError as err:
|
||||
_LOGGER.error("Missing required configuration key: %s", err)
|
||||
raise ConfigEntryNotReady(f"Missing required configuration key: {err}")
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Unexpected error setting up AI Agent HA")
|
||||
raise ConfigEntryNotReady(f"Error setting up AI Agent HA: {err}")
|
||||
|
||||
# Modify the query service handler to use the correct provider
|
||||
async def async_handle_query(call):
|
||||
"""Handle the query service call."""
|
||||
try:
|
||||
# Check if agents are available
|
||||
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
|
||||
_LOGGER.error(
|
||||
"No AI agents available. Please configure the integration first."
|
||||
)
|
||||
result = {"error": "No AI agents configured"}
|
||||
hass.bus.async_fire("ai_agent_ha_response", result)
|
||||
return
|
||||
|
||||
provider = call.data.get("provider")
|
||||
if provider not in hass.data[DOMAIN]["agents"]:
|
||||
# Get the first available provider
|
||||
available_providers = list(hass.data[DOMAIN]["agents"].keys())
|
||||
if not available_providers:
|
||||
_LOGGER.error("No AI agents available")
|
||||
result = {"error": "No AI agents configured"}
|
||||
hass.bus.async_fire("ai_agent_ha_response", result)
|
||||
return
|
||||
provider = available_providers[0]
|
||||
_LOGGER.debug(f"Using fallback provider: {provider}")
|
||||
|
||||
agent = hass.data[DOMAIN]["agents"][provider]
|
||||
result = await agent.process_query(
|
||||
call.data.get("prompt", ""),
|
||||
provider=provider,
|
||||
debug=call.data.get("debug", False),
|
||||
)
|
||||
hass.bus.async_fire("ai_agent_ha_response", result)
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error processing query: {e}")
|
||||
result = {"error": str(e)}
|
||||
hass.bus.async_fire("ai_agent_ha_response", result)
|
||||
|
||||
async def async_handle_create_automation(call):
|
||||
"""Handle the create_automation service call."""
|
||||
try:
|
||||
# Check if agents are available
|
||||
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
|
||||
_LOGGER.error(
|
||||
"No AI agents available. Please configure the integration first."
|
||||
)
|
||||
return {"error": "No AI agents configured"}
|
||||
|
||||
provider = call.data.get("provider")
|
||||
if provider not in hass.data[DOMAIN]["agents"]:
|
||||
# Get the first available provider
|
||||
available_providers = list(hass.data[DOMAIN]["agents"].keys())
|
||||
if not available_providers:
|
||||
_LOGGER.error("No AI agents available")
|
||||
return {"error": "No AI agents configured"}
|
||||
provider = available_providers[0]
|
||||
_LOGGER.debug(f"Using fallback provider: {provider}")
|
||||
|
||||
agent = hass.data[DOMAIN]["agents"][provider]
|
||||
result = await agent.create_automation(call.data.get("automation", {}))
|
||||
return result
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error creating automation: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def async_handle_save_prompt_history(call):
|
||||
"""Handle the save_prompt_history service call."""
|
||||
try:
|
||||
# Check if agents are available
|
||||
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
|
||||
_LOGGER.error(
|
||||
"No AI agents available. Please configure the integration first."
|
||||
)
|
||||
return {"error": "No AI agents configured"}
|
||||
|
||||
provider = call.data.get("provider")
|
||||
if provider not in hass.data[DOMAIN]["agents"]:
|
||||
# Get the first available provider
|
||||
available_providers = list(hass.data[DOMAIN]["agents"].keys())
|
||||
if not available_providers:
|
||||
_LOGGER.error("No AI agents available")
|
||||
return {"error": "No AI agents configured"}
|
||||
provider = available_providers[0]
|
||||
_LOGGER.debug(f"Using fallback provider: {provider}")
|
||||
|
||||
agent = hass.data[DOMAIN]["agents"][provider]
|
||||
user_id = call.context.user_id if call.context.user_id else "default"
|
||||
result = await agent.save_user_prompt_history(
|
||||
user_id, call.data.get("history", [])
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error saving prompt history: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def async_handle_load_prompt_history(call):
|
||||
"""Handle the load_prompt_history service call."""
|
||||
try:
|
||||
# Check if agents are available
|
||||
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
|
||||
_LOGGER.error(
|
||||
"No AI agents available. Please configure the integration first."
|
||||
)
|
||||
return {"error": "No AI agents configured"}
|
||||
|
||||
provider = call.data.get("provider")
|
||||
if provider not in hass.data[DOMAIN]["agents"]:
|
||||
# Get the first available provider
|
||||
available_providers = list(hass.data[DOMAIN]["agents"].keys())
|
||||
if not available_providers:
|
||||
_LOGGER.error("No AI agents available")
|
||||
return {"error": "No AI agents configured"}
|
||||
provider = available_providers[0]
|
||||
_LOGGER.debug(f"Using fallback provider: {provider}")
|
||||
|
||||
agent = hass.data[DOMAIN]["agents"][provider]
|
||||
user_id = call.context.user_id if call.context.user_id else "default"
|
||||
result = await agent.load_user_prompt_history(user_id)
|
||||
_LOGGER.debug("Load prompt history result: %s", result)
|
||||
return result
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error loading prompt history: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def async_handle_create_dashboard(call):
|
||||
"""Handle the create_dashboard service call."""
|
||||
try:
|
||||
# Check if agents are available
|
||||
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
|
||||
_LOGGER.error(
|
||||
"No AI agents available. Please configure the integration first."
|
||||
)
|
||||
return {"error": "No AI agents configured"}
|
||||
|
||||
provider = call.data.get("provider")
|
||||
if provider not in hass.data[DOMAIN]["agents"]:
|
||||
# Get the first available provider
|
||||
available_providers = list(hass.data[DOMAIN]["agents"].keys())
|
||||
if not available_providers:
|
||||
_LOGGER.error("No AI agents available")
|
||||
return {"error": "No AI agents configured"}
|
||||
provider = available_providers[0]
|
||||
_LOGGER.debug(f"Using fallback provider: {provider}")
|
||||
|
||||
agent = hass.data[DOMAIN]["agents"][provider]
|
||||
|
||||
# Parse dashboard config if it's a string
|
||||
dashboard_config = call.data.get("dashboard_config", {})
|
||||
if isinstance(dashboard_config, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
dashboard_config = json.loads(dashboard_config)
|
||||
except json.JSONDecodeError as e:
|
||||
_LOGGER.error(f"Invalid JSON in dashboard_config: {e}")
|
||||
return {"error": f"Invalid JSON in dashboard_config: {e}"}
|
||||
|
||||
result = await agent.create_dashboard(dashboard_config)
|
||||
return result
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error creating dashboard: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def async_handle_update_dashboard(call):
|
||||
"""Handle the update_dashboard service call."""
|
||||
try:
|
||||
# Check if agents are available
|
||||
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
|
||||
_LOGGER.error(
|
||||
"No AI agents available. Please configure the integration first."
|
||||
)
|
||||
return {"error": "No AI agents configured"}
|
||||
|
||||
provider = call.data.get("provider")
|
||||
if provider not in hass.data[DOMAIN]["agents"]:
|
||||
# Get the first available provider
|
||||
available_providers = list(hass.data[DOMAIN]["agents"].keys())
|
||||
if not available_providers:
|
||||
_LOGGER.error("No AI agents available")
|
||||
return {"error": "No AI agents configured"}
|
||||
provider = available_providers[0]
|
||||
_LOGGER.debug(f"Using fallback provider: {provider}")
|
||||
|
||||
agent = hass.data[DOMAIN]["agents"][provider]
|
||||
|
||||
# Parse dashboard config if it's a string
|
||||
dashboard_config = call.data.get("dashboard_config", {})
|
||||
if isinstance(dashboard_config, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
dashboard_config = json.loads(dashboard_config)
|
||||
except json.JSONDecodeError as e:
|
||||
_LOGGER.error(f"Invalid JSON in dashboard_config: {e}")
|
||||
return {"error": f"Invalid JSON in dashboard_config: {e}"}
|
||||
|
||||
dashboard_url = call.data.get("dashboard_url", "")
|
||||
if not dashboard_url:
|
||||
return {"error": "Dashboard URL is required"}
|
||||
|
||||
result = await agent.update_dashboard(dashboard_url, dashboard_config)
|
||||
return result
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error updating dashboard: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
# Register services
|
||||
hass.services.async_register(DOMAIN, "query", async_handle_query)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_automation", async_handle_create_automation
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "save_prompt_history", async_handle_save_prompt_history
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "load_prompt_history", async_handle_load_prompt_history
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_dashboard", async_handle_create_dashboard
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "update_dashboard", async_handle_update_dashboard
|
||||
)
|
||||
|
||||
# Register static path for frontend
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
StaticPathConfig(
|
||||
"/frontend/ai_agent_ha",
|
||||
hass.config.path("custom_components/ai_agent_ha/frontend"),
|
||||
False,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Panel registration with proper error handling
|
||||
panel_name = "ai_agent_ha"
|
||||
try:
|
||||
if await _panel_exists(hass, panel_name):
|
||||
_LOGGER.debug("AI Agent HA panel already exists, skipping registration")
|
||||
return True
|
||||
|
||||
_LOGGER.debug("Registering AI Agent HA panel")
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
component_name="custom",
|
||||
sidebar_title="AI Agent HA",
|
||||
sidebar_icon="mdi:robot",
|
||||
frontend_url_path=panel_name,
|
||||
require_admin=False,
|
||||
config={
|
||||
"_panel_custom": {
|
||||
"name": "ai_agent_ha-panel",
|
||||
"module_url": "/frontend/ai_agent_ha/ai_agent_ha-panel.js",
|
||||
"embed_iframe": False,
|
||||
}
|
||||
},
|
||||
)
|
||||
_LOGGER.debug("AI Agent HA panel registered successfully")
|
||||
except Exception as e:
|
||||
_LOGGER.warning("Panel registration error: %s", str(e))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if await _panel_exists(hass, "ai_agent_ha"):
|
||||
try:
|
||||
from homeassistant.components.frontend import async_remove_panel
|
||||
|
||||
async_remove_panel(hass, "ai_agent_ha")
|
||||
_LOGGER.debug("AI Agent HA panel removed successfully")
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Error removing panel: %s", str(e))
|
||||
|
||||
# Remove services
|
||||
hass.services.async_remove(DOMAIN, "query")
|
||||
hass.services.async_remove(DOMAIN, "create_automation")
|
||||
hass.services.async_remove(DOMAIN, "save_prompt_history")
|
||||
hass.services.async_remove(DOMAIN, "load_prompt_history")
|
||||
hass.services.async_remove(DOMAIN, "create_dashboard")
|
||||
hass.services.async_remove(DOMAIN, "update_dashboard")
|
||||
# Remove data
|
||||
if DOMAIN in hass.data:
|
||||
hass.data.pop(DOMAIN)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _panel_exists(hass: HomeAssistant, panel_name: str) -> bool:
|
||||
"""Check if a panel already exists."""
|
||||
try:
|
||||
return hasattr(hass.data, "frontend_panels") and panel_name in hass.data.get(
|
||||
"frontend_panels", {}
|
||||
)
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Error checking panel existence: %s", str(e))
|
||||
return False
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,917 +0,0 @@
|
||||
"""Config flow for AI Agent HA integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
)
|
||||
|
||||
from .agent import (
|
||||
fetch_gemini_models,
|
||||
fetch_openai_compatible_models,
|
||||
fetch_openai_models,
|
||||
)
|
||||
from .const import (
|
||||
CONF_LOCAL_OLLAMA_URL,
|
||||
CONF_OPENAI_BASE_URL,
|
||||
CONF_OPENAI_COMPATIBLE_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROVIDERS = {
|
||||
"llama": "Llama",
|
||||
"openai": "OpenAI",
|
||||
"gemini": "Google Gemini",
|
||||
"openrouter": "OpenRouter",
|
||||
"anthropic": "Anthropic (Claude)",
|
||||
"alter": "Alter",
|
||||
"zai": "z.ai",
|
||||
"local_ollama": "Local Ollama",
|
||||
"openai_compatible": "Local OpenAI-Compatible (e.g. LM Studio, vLLM)",
|
||||
}
|
||||
|
||||
TOKEN_FIELD_NAMES = {
|
||||
"llama": "llama_token",
|
||||
"openai": "openai_token",
|
||||
"gemini": "gemini_token",
|
||||
"openrouter": "openrouter_token",
|
||||
"anthropic": "anthropic_token",
|
||||
"alter": "alter_token",
|
||||
"zai": "zai_token",
|
||||
"zai_endpoint": "zai_endpoint",
|
||||
"local_ollama": CONF_LOCAL_OLLAMA_URL, # For local Ollama models, we use URL instead of token
|
||||
"openai_compatible": CONF_OPENAI_COMPATIBLE_URL, # For OpenAI-compatible endpoints
|
||||
}
|
||||
|
||||
TOKEN_LABELS = {
|
||||
"llama": "Llama API Token",
|
||||
"openai": "OpenAI API Key",
|
||||
"gemini": "Google Gemini API Key",
|
||||
"openrouter": "OpenRouter API Key",
|
||||
"anthropic": "Anthropic API Key",
|
||||
"alter": "Alter API Key",
|
||||
"zai": "z.ai API Key",
|
||||
"zai_endpoint": "z.ai API Endpoint Type",
|
||||
"local_ollama": "Local Ollama API URL (e.g., http://localhost:11434/api/generate)",
|
||||
"openai_compatible": "OpenAI-Compatible URL (e.g., http://example.com/v1/ or http://localhost:8080/v1/). Must end with /v1/",
|
||||
}
|
||||
|
||||
DEFAULT_MODELS = {
|
||||
"llama": "Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
"openai": "gpt-5",
|
||||
"gemini": "gemini-2.5-flash",
|
||||
"openrouter": "openai/gpt-4o",
|
||||
"anthropic": "claude-sonnet-4-5-20250929",
|
||||
"alter": "", # User enters custom model
|
||||
"zai": "glm-4.7", # Z.ai's latest flagship model
|
||||
"local_ollama": "llama3.2", # Updated to use llama3.2 as default for local Ollama
|
||||
"openai_compatible": "", # User enters custom model for OpenAI-compatible endpoint
|
||||
}
|
||||
|
||||
AVAILABLE_MODELS = {
|
||||
"openai": [
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o3",
|
||||
"o3-mini",
|
||||
"o4-mini",
|
||||
"o1",
|
||||
"o1-preview",
|
||||
"o1-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4",
|
||||
"gpt-3.5-turbo",
|
||||
],
|
||||
"gemini": [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.5-flash-preview",
|
||||
"gemini-2.5-pro-preview",
|
||||
],
|
||||
"openrouter": [
|
||||
"openai/gpt-4o",
|
||||
"openai/gpt-4-turbo",
|
||||
"openai/gpt-3.5-turbo",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3-sonnet",
|
||||
"anthropic/claude-3-haiku",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"meta-llama/llama-3.2-90b-instruct",
|
||||
"google/gemini-pro",
|
||||
"mistralai/mixtral-8x7b-instruct",
|
||||
"deepseek/deepseek-r1",
|
||||
],
|
||||
"anthropic": [
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-haiku-20240307",
|
||||
],
|
||||
"llama": [
|
||||
"Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
"Llama-3.1-70B-Instruct",
|
||||
"Llama-3.1-8B-Instruct",
|
||||
"Llama-3.2-90B-Instruct",
|
||||
],
|
||||
# Alter - user enters custom model name only
|
||||
"alter": [
|
||||
"Custom...",
|
||||
],
|
||||
# z.ai - available models
|
||||
"zai": [
|
||||
"glm-4.7",
|
||||
"glm-4.6",
|
||||
"glm-4.5",
|
||||
"glm-4.5-air",
|
||||
"glm-4.5-x",
|
||||
"glm-4.5-airx",
|
||||
"glm-4.5-flash",
|
||||
"glm-4-32b-0414-128k",
|
||||
"Custom...",
|
||||
],
|
||||
# For local Ollama models, provide common models with llama3.2 as the default
|
||||
"local_ollama": [
|
||||
"llama3.2",
|
||||
"llama3",
|
||||
"llama3.1",
|
||||
"mistral",
|
||||
"mixtral",
|
||||
"deepseek-coder",
|
||||
"Custom...",
|
||||
],
|
||||
# For OpenAI-compatible endpoints, user should specify their model
|
||||
"openai_compatible": [
|
||||
"Custom...",
|
||||
],
|
||||
}
|
||||
|
||||
DEFAULT_PROVIDER = "openai"
|
||||
|
||||
|
||||
class AiAgentHaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg,misc]
|
||||
"""Handle a config flow for AI Agent HA."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get the options flow for this handler."""
|
||||
try:
|
||||
return AiAgentHaOptionsFlowHandler()
|
||||
except Exception as e:
|
||||
_LOGGER.error("Error creating options flow: %s", e)
|
||||
return None
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Check if this provider is already configured
|
||||
await self.async_set_unique_id(f"ai_agent_ha_{user_input['ai_provider']}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.config_data = {"ai_provider": user_input["ai_provider"]}
|
||||
return await self.async_step_configure()
|
||||
|
||||
# Show provider selection form
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("ai_provider"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"value": k, "label": v} for k, v in PROVIDERS.items()
|
||||
]
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_configure(self, user_input=None):
|
||||
"""Handle the configuration step for the selected provider."""
|
||||
errors = {}
|
||||
provider = self.config_data["ai_provider"]
|
||||
token_field = TOKEN_FIELD_NAMES[provider]
|
||||
token_label = TOKEN_LABELS[provider]
|
||||
default_model = DEFAULT_MODELS[provider]
|
||||
# For Alter provider, default to "Custom..." for the dropdown since model is user-provided
|
||||
dropdown_default = "Custom..." if provider == "alter" else default_model
|
||||
available_models = AVAILABLE_MODELS.get(provider, [default_model])
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
# Validate the token
|
||||
token_value = user_input.get(token_field)
|
||||
if not token_value:
|
||||
errors[token_field] = "required"
|
||||
raise InvalidApiKey
|
||||
|
||||
# Store the configuration data
|
||||
self.config_data[token_field] = token_value
|
||||
|
||||
# For z.ai, store endpoint type
|
||||
if provider == "zai":
|
||||
endpoint_type = user_input.get("zai_endpoint", "general")
|
||||
self.config_data["zai_endpoint"] = endpoint_type
|
||||
|
||||
# For OpenAI, store Base URL (defaults to official endpoint if unchanged)
|
||||
if provider == "openai":
|
||||
base_url = (user_input.get(CONF_OPENAI_BASE_URL) or "").strip()
|
||||
self.config_data[CONF_OPENAI_BASE_URL] = (
|
||||
base_url or "https://api.openai.com/v1"
|
||||
)
|
||||
# For OpenAI, move to next step to select model from dynamic list
|
||||
return await self.async_step_configure_openai_models()
|
||||
|
||||
# For OpenAI-Compatible, store Base URL + optional API key, then move on
|
||||
if provider == "openai_compatible":
|
||||
base_url = (
|
||||
user_input.get(CONF_OPENAI_COMPATIBLE_URL) or ""
|
||||
).strip()
|
||||
self.config_data[CONF_OPENAI_COMPATIBLE_URL] = base_url
|
||||
api_key = (
|
||||
user_input.get("openai_compatible_api_key") or ""
|
||||
).strip()
|
||||
self.config_data["openai_compatible_api_key"] = api_key
|
||||
# Move to next step to select model from dynamic list
|
||||
return await self.async_step_configure_openai_compatible_models()
|
||||
|
||||
# Add model configuration if provided
|
||||
selected_model = user_input.get("model")
|
||||
custom_model = user_input.get("custom_model")
|
||||
|
||||
_LOGGER.debug(
|
||||
f"Config flow - Provider: {provider}, Selected model: {selected_model}, Custom model: {custom_model}"
|
||||
)
|
||||
|
||||
# Initialize models dict if it doesn't exist
|
||||
if "models" not in self.config_data:
|
||||
self.config_data["models"] = {}
|
||||
|
||||
if custom_model and custom_model.strip():
|
||||
# Use custom model if provided and not empty
|
||||
self.config_data["models"][provider] = custom_model.strip()
|
||||
elif selected_model and selected_model != "Custom...":
|
||||
# Use selected model if it's not the "Custom..." option
|
||||
self.config_data["models"][provider] = selected_model
|
||||
else:
|
||||
# For local_ollama, openai_compatible, alter, and zai providers, allow empty model name
|
||||
if provider in (
|
||||
"local_ollama",
|
||||
"openai_compatible",
|
||||
"alter",
|
||||
"zai",
|
||||
):
|
||||
self.config_data["models"][provider] = ""
|
||||
else:
|
||||
# Fallback to default model for other providers
|
||||
self.config_data["models"][provider] = default_model
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"AI Agent HA ({PROVIDERS[provider]})",
|
||||
data=self.config_data,
|
||||
)
|
||||
except InvalidApiKey:
|
||||
errors["base"] = "invalid_api_key"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if provider == "zai":
|
||||
# For z.ai provider, we need token, endpoint type, and optional model name
|
||||
model_options = AVAILABLE_MODELS.get("zai", ["Custom..."])
|
||||
schema_dict = {
|
||||
vol.Required(token_field): TextSelector(
|
||||
TextSelectorConfig(type="password")
|
||||
),
|
||||
vol.Optional("zai_endpoint", default="general"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"value": "general", "label": "General Purpose"},
|
||||
{"value": "coding", "label": "Coding (3× usage, 1/7 cost)"},
|
||||
]
|
||||
)
|
||||
),
|
||||
vol.Optional("model", default="glm-4.7"): SelectSelector(
|
||||
SelectSelectorConfig(options=model_options)
|
||||
),
|
||||
vol.Optional("custom_model"): TextSelector(
|
||||
TextSelectorConfig(type="text")
|
||||
),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": token_label,
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
if provider == "local_ollama":
|
||||
# For local_ollama provider, we need both URL and optional model name
|
||||
schema_dict = {
|
||||
vol.Required(CONF_LOCAL_OLLAMA_URL): TextSelector(
|
||||
TextSelectorConfig(type="text")
|
||||
),
|
||||
}
|
||||
|
||||
# Add model selection
|
||||
model_options = AVAILABLE_MODELS.get("local_ollama", ["Custom..."])
|
||||
schema_dict[vol.Optional("model", default="Custom...")] = SelectSelector(
|
||||
SelectSelectorConfig(options=model_options)
|
||||
)
|
||||
schema_dict[vol.Optional("custom_model")] = TextSelector(
|
||||
TextSelectorConfig(type="text")
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": "Local Ollama API URL", # nosec B105 - UI label string shown next to the URL field, not a credential
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
if provider == "openai_compatible":
|
||||
# For openai_compatible provider, we need base URL + optional API key.
|
||||
# Many local endpoints (LM Studio, vLLM) need no key; gateways like
|
||||
# Open WebUI or LiteLLM require one. Models are fetched in the next step.
|
||||
schema_dict = {
|
||||
vol.Required(CONF_OPENAI_COMPATIBLE_URL): TextSelector(
|
||||
TextSelectorConfig(type="text")
|
||||
),
|
||||
vol.Optional("openai_compatible_api_key", default=""): TextSelector(
|
||||
TextSelectorConfig(type="password")
|
||||
),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": "Local OpenAI-Compatible URL", # nosec B105 - UI label for config form, not a credential
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
if provider == "openai":
|
||||
# For OpenAI provider, first step: API Key + Base URL
|
||||
# Model selection happens in the next step after we fetch available models
|
||||
schema_dict = {
|
||||
vol.Required(token_field): TextSelector(
|
||||
TextSelectorConfig(type="password")
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_OPENAI_BASE_URL,
|
||||
default="https://api.openai.com/v1",
|
||||
): TextSelector(TextSelectorConfig(type="text")),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": token_label,
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
# Build schema for other providers
|
||||
schema_dict = {
|
||||
vol.Required(token_field): TextSelector(
|
||||
TextSelectorConfig(type="password")
|
||||
),
|
||||
}
|
||||
|
||||
# Add model selection if available
|
||||
if available_models:
|
||||
# For Gemini, fetch models dynamically
|
||||
if provider == "gemini":
|
||||
token_value = self.config_data.get("gemini_token")
|
||||
model_list = await fetch_gemini_models(token_value)
|
||||
if "Custom..." not in model_list:
|
||||
model_list.insert(0, "Custom...")
|
||||
model_options = model_list
|
||||
else:
|
||||
# Add predefined models + custom option (avoid duplicating "Custom...")
|
||||
if "Custom..." in available_models:
|
||||
model_options = available_models
|
||||
else:
|
||||
model_options = available_models + ["Custom..."]
|
||||
|
||||
schema_dict[vol.Optional("model", default=dropdown_default)] = (
|
||||
SelectSelector(SelectSelectorConfig(options=model_options))
|
||||
)
|
||||
schema_dict[vol.Optional("custom_model")] = TextSelector(
|
||||
TextSelectorConfig(type="text")
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": token_label,
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_configure_openai_models(self, user_input=None):
|
||||
"""Handle the OpenAI model selection step with dynamic model list."""
|
||||
errors = {}
|
||||
provider = "openai"
|
||||
token = self.config_data.get("openai_token")
|
||||
base_url = self.config_data.get(
|
||||
CONF_OPENAI_BASE_URL, "https://api.openai.com/v1"
|
||||
)
|
||||
|
||||
# Fetch available models dynamically
|
||||
model_list = await fetch_openai_models(base_url, token)
|
||||
|
||||
# Ensure "Custom..." is always available
|
||||
if "Custom..." not in model_list:
|
||||
model_list.insert(0, "Custom...")
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
selected_model = user_input.get("model")
|
||||
custom_model = user_input.get("custom_model")
|
||||
|
||||
# Initialize models dict if it doesn't exist
|
||||
if "models" not in self.config_data:
|
||||
self.config_data["models"] = {}
|
||||
|
||||
if custom_model and custom_model.strip():
|
||||
self.config_data["models"][provider] = custom_model.strip()
|
||||
elif selected_model and selected_model != "Custom...":
|
||||
self.config_data["models"][provider] = selected_model
|
||||
else:
|
||||
self.config_data["models"][provider] = ""
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"AI Agent HA ({PROVIDERS[provider]})",
|
||||
data=self.config_data,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception in OpenAI model selection")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
schema_dict = {
|
||||
vol.Optional("model", default="Custom..."): SelectSelector(
|
||||
SelectSelectorConfig(options=model_list)
|
||||
),
|
||||
vol.Optional("custom_model"): TextSelector(TextSelectorConfig(type="text")),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_openai_models",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_configure_openai_compatible_models(self, user_input=None):
|
||||
"""Handle the OpenAI-Compatible model selection step with dynamic model list."""
|
||||
errors = {}
|
||||
provider = "openai_compatible"
|
||||
base_url = self.config_data.get(CONF_OPENAI_COMPATIBLE_URL, "")
|
||||
api_key = self.config_data.get("openai_compatible_api_key") or ""
|
||||
|
||||
# Fetch available models dynamically if the endpoint supports it
|
||||
model_list = await fetch_openai_compatible_models(base_url, api_key or None)
|
||||
|
||||
# Ensure "Custom..." is always available
|
||||
if "Custom..." not in model_list:
|
||||
model_list.insert(0, "Custom...")
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
selected_model = user_input.get("model")
|
||||
custom_model = user_input.get("custom_model")
|
||||
|
||||
# Initialize models dict if it doesn't exist
|
||||
if "models" not in self.config_data:
|
||||
self.config_data["models"] = {}
|
||||
|
||||
if custom_model and custom_model.strip():
|
||||
self.config_data["models"][provider] = custom_model.strip()
|
||||
elif selected_model and selected_model != "Custom...":
|
||||
self.config_data["models"][provider] = selected_model
|
||||
else:
|
||||
self.config_data["models"][provider] = ""
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"AI Agent HA ({PROVIDERS[provider]})",
|
||||
data=self.config_data,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unexpected exception in OpenAI-Compatible model selection"
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
|
||||
schema_dict = {
|
||||
vol.Optional("model", default="Custom..."): SelectSelector(
|
||||
SelectSelectorConfig(options=model_list)
|
||||
),
|
||||
vol.Optional("custom_model"): TextSelector(TextSelectorConfig(type="text")),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_openai_compatible_models",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class InvalidApiKey(HomeAssistantError):
|
||||
"""Error to indicate there is an invalid API key."""
|
||||
|
||||
|
||||
class AiAgentHaOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow for AI Agent HA."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize options flow."""
|
||||
self.options_data = {}
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle the initial options step - provider selection."""
|
||||
current_provider = self.config_entry.data.get("ai_provider", DEFAULT_PROVIDER)
|
||||
|
||||
if user_input is not None:
|
||||
# Store selected provider and move to configure step
|
||||
self.options_data = {
|
||||
"ai_provider": user_input["ai_provider"],
|
||||
"current_provider": current_provider,
|
||||
}
|
||||
return await self.async_step_configure_options()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"ai_provider", default=current_provider
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"value": k, "label": v} for k, v in PROVIDERS.items()
|
||||
]
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
description_placeholders={"current_provider": PROVIDERS[current_provider]},
|
||||
)
|
||||
|
||||
async def async_step_configure_options(self, user_input=None):
|
||||
"""Handle the configuration step for the selected provider in options."""
|
||||
errors = {}
|
||||
provider = self.options_data["ai_provider"]
|
||||
current_provider = self.options_data["current_provider"]
|
||||
token_field = TOKEN_FIELD_NAMES[provider]
|
||||
token_label = TOKEN_LABELS[provider]
|
||||
|
||||
# Get current configuration
|
||||
current_models = self.config_entry.data.get("models", {})
|
||||
current_model = current_models.get(provider, DEFAULT_MODELS[provider])
|
||||
# For Alter provider, if model is empty, default to "Custom..." for the dropdown
|
||||
if provider == "alter" and not current_model:
|
||||
current_model = "Custom..."
|
||||
current_token = self.config_entry.data.get(token_field, "")
|
||||
available_models = AVAILABLE_MODELS.get(provider, [DEFAULT_MODELS[provider]])
|
||||
|
||||
# Use current token if provider hasn't changed, otherwise empty
|
||||
display_token = current_token if provider == current_provider else ""
|
||||
|
||||
# Determine if current model is a custom model (not in available models list)
|
||||
# and prepare model dropdown and custom model field defaults
|
||||
model_options = available_models
|
||||
if "Custom..." not in model_options:
|
||||
model_options = model_options + ["Custom..."]
|
||||
|
||||
# Check if current_model is a custom model (not in the available models)
|
||||
# Remove "Custom..." from the check since it's the selector option, not a real model
|
||||
available_models_without_custom = [
|
||||
m for m in available_models if m != "Custom..."
|
||||
]
|
||||
is_custom_model = (
|
||||
current_model
|
||||
and current_model not in available_models_without_custom
|
||||
and current_model != "Custom..."
|
||||
)
|
||||
|
||||
if is_custom_model:
|
||||
# Current model is a custom model - show "Custom..." in dropdown and populate custom field
|
||||
model_default = "Custom..."
|
||||
custom_model_default = current_model
|
||||
else:
|
||||
# Current model is a standard model or empty
|
||||
model_default = current_model if current_model else "Custom..."
|
||||
custom_model_default = ""
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
token_value = user_input.get(token_field)
|
||||
if not token_value:
|
||||
errors[token_field] = "required"
|
||||
else:
|
||||
# Prepare the updated configuration
|
||||
updated_data = dict(self.config_entry.data)
|
||||
updated_data["ai_provider"] = provider
|
||||
updated_data[token_field] = token_value
|
||||
|
||||
# Update model configuration
|
||||
selected_model = user_input.get("model")
|
||||
custom_model = user_input.get("custom_model")
|
||||
|
||||
# For zai, update endpoint type
|
||||
if provider == "zai":
|
||||
endpoint_type = user_input.get("zai_endpoint", "general")
|
||||
updated_data["zai_endpoint"] = endpoint_type
|
||||
|
||||
# For OpenAI, update Base URL (default to official if blank)
|
||||
if provider == "openai":
|
||||
base_url = (user_input.get(CONF_OPENAI_BASE_URL) or "").strip()
|
||||
updated_data[CONF_OPENAI_BASE_URL] = (
|
||||
base_url or "https://api.openai.com/v1"
|
||||
)
|
||||
|
||||
# For OpenAI-Compatible, update the optional API key
|
||||
if provider == "openai_compatible":
|
||||
updated_data["openai_compatible_api_key"] = (
|
||||
user_input.get("openai_compatible_api_key") or ""
|
||||
).strip()
|
||||
|
||||
# Initialize models dict if it doesn't exist
|
||||
if "models" not in updated_data:
|
||||
updated_data["models"] = {}
|
||||
|
||||
if custom_model and custom_model.strip():
|
||||
# Use custom model if provided and not empty
|
||||
updated_data["models"][provider] = custom_model.strip()
|
||||
elif selected_model and selected_model != "Custom...":
|
||||
# Use selected model if it's not the "Custom..." option
|
||||
updated_data["models"][provider] = selected_model
|
||||
else:
|
||||
# For local_ollama, openai_compatible, alter, and zai providers, allow empty model name
|
||||
if provider in (
|
||||
"local_ollama",
|
||||
"openai_compatible",
|
||||
"alter",
|
||||
"zai",
|
||||
):
|
||||
updated_data["models"][provider] = ""
|
||||
else:
|
||||
# Ensure we keep the current model or use default for other providers
|
||||
if provider not in updated_data["models"]:
|
||||
updated_data["models"][provider] = DEFAULT_MODELS[
|
||||
provider
|
||||
]
|
||||
|
||||
_LOGGER.debug(
|
||||
f"Options flow - Final model config for {provider}: {updated_data['models'].get(provider)}"
|
||||
)
|
||||
|
||||
# Update the config entry
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry, data=updated_data
|
||||
)
|
||||
|
||||
return self.async_create_entry(title="", data={})
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception in options flow")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
# Build schema for the selected provider in options
|
||||
if provider == "zai":
|
||||
current_endpoint = self.config_entry.data.get("zai_endpoint", "general")
|
||||
model_options = AVAILABLE_MODELS.get("zai", ["glm-4.7"])
|
||||
# Ensure "Custom..." is in model options
|
||||
if "Custom..." not in model_options:
|
||||
model_options = model_options + ["Custom..."]
|
||||
schema_dict = {
|
||||
vol.Required(token_field, default=display_token): TextSelector(
|
||||
TextSelectorConfig(type="password")
|
||||
),
|
||||
vol.Optional("zai_endpoint", default=current_endpoint): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"value": "general", "label": "General Purpose"},
|
||||
{"value": "coding", "label": "Coding (3× usage, 1/7 cost)"},
|
||||
]
|
||||
)
|
||||
),
|
||||
vol.Optional("model", default=model_default): SelectSelector(
|
||||
SelectSelectorConfig(options=model_options)
|
||||
),
|
||||
vol.Optional(
|
||||
"custom_model", default=custom_model_default
|
||||
): TextSelector(TextSelectorConfig(type="text")),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_options",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": token_label,
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
if provider == "local_ollama":
|
||||
# For local_ollama provider, we need both URL and optional model name
|
||||
current_url = self.config_entry.data.get(CONF_LOCAL_OLLAMA_URL, "")
|
||||
|
||||
schema_dict = {
|
||||
vol.Required(CONF_LOCAL_OLLAMA_URL, default=current_url): TextSelector(
|
||||
TextSelectorConfig(type="text")
|
||||
),
|
||||
}
|
||||
|
||||
# Add model selection
|
||||
model_options = AVAILABLE_MODELS.get("local_ollama", ["Custom..."])
|
||||
# Ensure "Custom..." is in model options
|
||||
if "Custom..." not in model_options:
|
||||
model_options = model_options + ["Custom..."]
|
||||
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
|
||||
SelectSelectorConfig(options=model_options)
|
||||
)
|
||||
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
|
||||
TextSelector(TextSelectorConfig(type="text"))
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_options",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": "Local Ollama API URL", # nosec B105 - UI label string shown next to the URL field, not a credential
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
if provider == "openai_compatible":
|
||||
# For openai_compatible provider, we need URL + optional API key + model
|
||||
current_url = self.config_entry.data.get(CONF_OPENAI_COMPATIBLE_URL, "")
|
||||
current_api_key = (
|
||||
self.config_entry.data.get("openai_compatible_api_key") or ""
|
||||
)
|
||||
|
||||
# Fetch available models dynamically if the endpoint supports it
|
||||
model_list = await fetch_openai_compatible_models(
|
||||
current_url, current_api_key or None
|
||||
)
|
||||
|
||||
# Ensure "Custom..." is always available
|
||||
if "Custom..." not in model_list:
|
||||
model_list.insert(0, "Custom...")
|
||||
|
||||
schema_dict = {
|
||||
vol.Required(
|
||||
CONF_OPENAI_COMPATIBLE_URL, default=current_url
|
||||
): TextSelector(TextSelectorConfig(type="text")),
|
||||
vol.Optional(
|
||||
"openai_compatible_api_key", default=current_api_key
|
||||
): TextSelector(TextSelectorConfig(type="password")),
|
||||
}
|
||||
|
||||
# Add model selection with dynamic list
|
||||
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
|
||||
SelectSelectorConfig(options=model_list)
|
||||
)
|
||||
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
|
||||
TextSelector(TextSelectorConfig(type="text"))
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_options",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": "Local OpenAI-Compatible URL", # nosec B105 - UI label for config form, not a credential
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
if provider == "openai":
|
||||
# For OpenAI provider, we need token and optional Base URL
|
||||
# Pre-fill with official endpoint if not set
|
||||
current_base_url = (
|
||||
self.config_entry.data.get(CONF_OPENAI_BASE_URL)
|
||||
or "https://api.openai.com/v1"
|
||||
)
|
||||
|
||||
# Fetch available models dynamically
|
||||
current_token = self.config_entry.data.get("openai_token", "")
|
||||
model_list = await fetch_openai_models(current_base_url, current_token)
|
||||
|
||||
# Ensure "Custom..." is always available
|
||||
if "Custom..." not in model_list:
|
||||
model_list.insert(0, "Custom...")
|
||||
|
||||
schema_dict = {
|
||||
vol.Required(token_field, default=display_token): TextSelector(
|
||||
TextSelectorConfig(type="password")
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_OPENAI_BASE_URL, default=current_base_url
|
||||
): TextSelector(TextSelectorConfig(type="text")),
|
||||
}
|
||||
|
||||
# Add model selection with dynamic list
|
||||
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
|
||||
SelectSelectorConfig(options=model_list)
|
||||
)
|
||||
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
|
||||
TextSelector(TextSelectorConfig(type="text"))
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_options",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": token_label,
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
|
||||
# Build schema for other providers
|
||||
schema_dict = {
|
||||
vol.Required(token_field, default=display_token): TextSelector(
|
||||
TextSelectorConfig(type="password")
|
||||
),
|
||||
}
|
||||
|
||||
# Add model selection if available
|
||||
if available_models:
|
||||
# For Gemini, fetch models dynamically
|
||||
if provider == "gemini":
|
||||
current_token = self.config_entry.data.get("gemini_token", "")
|
||||
model_list = await fetch_gemini_models(current_token)
|
||||
if "Custom..." not in model_list:
|
||||
model_list.insert(0, "Custom...")
|
||||
model_options = model_list
|
||||
# model_options already has "Custom..." added above for other providers
|
||||
|
||||
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
|
||||
SelectSelectorConfig(options=model_options)
|
||||
)
|
||||
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
|
||||
TextSelector(TextSelectorConfig(type="text"))
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_options",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"token_label": token_label,
|
||||
"provider": PROVIDERS[provider],
|
||||
},
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Constants for the AI Agent HA integration."""
|
||||
|
||||
DOMAIN = "ai_agent_ha"
|
||||
CONF_API_KEY = "api_key"
|
||||
CONF_WEATHER_ENTITY = "weather_entity"
|
||||
|
||||
# AI Provider configuration keys
|
||||
CONF_LLAMA_TOKEN = "llama_token" # nosec B105
|
||||
CONF_OPENAI_TOKEN = "openai_token" # nosec B105
|
||||
CONF_OPENAI_BASE_URL = (
|
||||
"openai_base_url" # nosec B105 - configuration key, not a credential
|
||||
)
|
||||
CONF_GEMINI_TOKEN = "gemini_token" # nosec B105
|
||||
CONF_OPENROUTER_TOKEN = "openrouter_token" # nosec B105
|
||||
CONF_ANTHROPIC_TOKEN = "anthropic_token" # nosec B105
|
||||
CONF_ALTER_TOKEN = "alter_token" # nosec B105
|
||||
CONF_ZAI_TOKEN = "zai_token" # nosec B105
|
||||
CONF_LOCAL_OLLAMA_URL = "local_ollama_url"
|
||||
CONF_LOCAL_OLLAMA_MODEL = "local_ollama_model"
|
||||
CONF_OPENAI_COMPATIBLE_URL = "openai_compatible_url"
|
||||
CONF_LOCAL_URL = "local_url" # legacy alias for local_ollama_url
|
||||
|
||||
# Available AI providers
|
||||
AI_PROVIDERS = [
|
||||
"llama",
|
||||
"openai",
|
||||
"gemini",
|
||||
"openrouter",
|
||||
"anthropic",
|
||||
"alter",
|
||||
"zai",
|
||||
"local_ollama",
|
||||
"openai_compatible",
|
||||
]
|
||||
|
||||
# AI Provider constants
|
||||
CONF_MODELS = "models"
|
||||
|
||||
# Supported AI providers
|
||||
DEFAULT_AI_PROVIDER = "openai"
|
||||
@@ -1,390 +0,0 @@
|
||||
"""
|
||||
Dashboard templates and examples for AI agent to use when creating dashboards.
|
||||
"""
|
||||
|
||||
# Basic dashboard templates for different use cases
|
||||
DASHBOARD_TEMPLATES = {
|
||||
"simple_lights": {
|
||||
"title": "Lights Dashboard",
|
||||
"url_path": "lights",
|
||||
"icon": "mdi:lightbulb",
|
||||
"show_in_sidebar": True,
|
||||
"views": [
|
||||
{
|
||||
"title": "All Lights",
|
||||
"cards": [
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Living Room Lights",
|
||||
"entities": [], # To be filled with actual light entities
|
||||
},
|
||||
{
|
||||
"type": "light",
|
||||
"entity": "", # To be filled with main light entity
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"security": {
|
||||
"title": "Security Dashboard",
|
||||
"url_path": "security",
|
||||
"icon": "mdi:security",
|
||||
"show_in_sidebar": True,
|
||||
"views": [
|
||||
{
|
||||
"title": "Security Overview",
|
||||
"cards": [
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Sensors",
|
||||
"entities": [], # To be filled with sensor entities
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Cameras",
|
||||
"entities": [], # To be filled with camera entities
|
||||
},
|
||||
{
|
||||
"type": "alarm-panel",
|
||||
"entity": "", # To be filled with alarm panel entity
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"climate": {
|
||||
"title": "Climate Control",
|
||||
"url_path": "climate",
|
||||
"icon": "mdi:thermometer",
|
||||
"show_in_sidebar": True,
|
||||
"views": [
|
||||
{
|
||||
"title": "Temperature & Humidity",
|
||||
"cards": [
|
||||
{
|
||||
"type": "thermostat",
|
||||
"entity": "", # To be filled with climate.* thermostat entity (optional)
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Temperature History",
|
||||
"entities": [], # To be filled with temperature sensor entities
|
||||
"hours_to_show": 24,
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Temperature Sensors",
|
||||
"entities": [], # To be filled with temperature sensor entities (device_class: temperature)
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Humidity History",
|
||||
"entities": [], # To be filled with humidity sensor entities
|
||||
"hours_to_show": 24,
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Humidity Sensors",
|
||||
"entities": [], # To be filled with humidity sensor entities (device_class: humidity)
|
||||
},
|
||||
{
|
||||
"type": "weather-forecast",
|
||||
"entity": "", # To be filled with weather entity (optional)
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"media": {
|
||||
"title": "Media Center",
|
||||
"url_path": "media",
|
||||
"icon": "mdi:play",
|
||||
"show_in_sidebar": True,
|
||||
"views": [
|
||||
{
|
||||
"title": "Media Players",
|
||||
"cards": [
|
||||
{
|
||||
"type": "media-control",
|
||||
"entity": "", # To be filled with media player entity
|
||||
},
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "All Media Players",
|
||||
"entities": [], # To be filled with media player entities
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"energy": {
|
||||
"title": "Energy Monitoring",
|
||||
"url_path": "energy",
|
||||
"icon": "mdi:lightning-bolt",
|
||||
"show_in_sidebar": True,
|
||||
"views": [
|
||||
{
|
||||
"title": "Energy Usage",
|
||||
"cards": [
|
||||
{"type": "energy-distribution", "title": "Energy Distribution"},
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Power Sensors",
|
||||
"entities": [], # To be filled with power sensor entities
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Power Usage",
|
||||
"entities": [], # To be filled with power entities
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Card type examples and their typical use cases
|
||||
CARD_EXAMPLES = {
|
||||
"entities": {
|
||||
"description": "Shows a list of entities with their states",
|
||||
"example": {
|
||||
"type": "entities",
|
||||
"title": "Living Room",
|
||||
"entities": [
|
||||
"light.living_room_main",
|
||||
"switch.living_room_fan",
|
||||
"sensor.living_room_temperature",
|
||||
],
|
||||
},
|
||||
},
|
||||
"glance": {
|
||||
"description": "Shows entities in a compact grid format",
|
||||
"example": {
|
||||
"type": "glance",
|
||||
"title": "Quick Overview",
|
||||
"entities": [
|
||||
"binary_sensor.front_door",
|
||||
"binary_sensor.back_door",
|
||||
"binary_sensor.garage_door",
|
||||
],
|
||||
},
|
||||
},
|
||||
"thermostat": {
|
||||
"description": "Controls and displays thermostat information",
|
||||
"example": {"type": "thermostat", "entity": "climate.main_thermostat"},
|
||||
},
|
||||
"weather-forecast": {
|
||||
"description": "Shows weather information and forecast",
|
||||
"example": {
|
||||
"type": "weather-forecast",
|
||||
"entity": "weather.home",
|
||||
"name": "Weather",
|
||||
},
|
||||
},
|
||||
"media-control": {
|
||||
"description": "Controls media players with full interface",
|
||||
"example": {"type": "media-control", "entity": "media_player.living_room_tv"},
|
||||
},
|
||||
"light": {
|
||||
"description": "Dedicated light control card",
|
||||
"example": {"type": "light", "entity": "light.living_room_main"},
|
||||
},
|
||||
"alarm-panel": {
|
||||
"description": "Security alarm panel interface",
|
||||
"example": {"type": "alarm-panel", "entity": "alarm_control_panel.home_alarm"},
|
||||
},
|
||||
"picture-entity": {
|
||||
"description": "Shows entity state with a background image",
|
||||
"example": {
|
||||
"type": "picture-entity",
|
||||
"entity": "light.living_room",
|
||||
"image": "/local/living_room.jpg",
|
||||
},
|
||||
},
|
||||
"history-graph": {
|
||||
"description": "Shows historical data as a graph",
|
||||
"example": {
|
||||
"type": "history-graph",
|
||||
"title": "Temperature History",
|
||||
"entities": ["sensor.temperature_indoor", "sensor.temperature_outdoor"],
|
||||
"hours_to_show": 24,
|
||||
},
|
||||
},
|
||||
"gauge": {
|
||||
"description": "Shows a single entity value as a gauge",
|
||||
"example": {
|
||||
"type": "gauge",
|
||||
"entity": "sensor.cpu_temperature",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"name": "CPU Temperature",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Predefined color schemes and icons
|
||||
COMMON_ICONS = {
|
||||
"lights": "mdi:lightbulb",
|
||||
"security": "mdi:security",
|
||||
"climate": "mdi:thermometer",
|
||||
"energy": "mdi:lightning-bolt",
|
||||
"media": "mdi:play",
|
||||
"kitchen": "mdi:chef-hat",
|
||||
"bedroom": "mdi:bed",
|
||||
"bathroom": "mdi:shower",
|
||||
"living_room": "mdi:sofa",
|
||||
"garage": "mdi:garage",
|
||||
"garden": "mdi:flower",
|
||||
"office": "mdi:desk",
|
||||
"basement": "mdi:stairs-down",
|
||||
"attic": "mdi:stairs-up",
|
||||
}
|
||||
|
||||
|
||||
def get_template_for_entities(entities, dashboard_type="general"):
|
||||
"""Generate a dashboard template based on available entities."""
|
||||
template = {
|
||||
"title": f"{dashboard_type.title()} Dashboard",
|
||||
"url_path": dashboard_type.lower().replace(" ", "-"),
|
||||
"icon": COMMON_ICONS.get(dashboard_type, "mdi:view-dashboard"),
|
||||
"show_in_sidebar": True,
|
||||
"views": [],
|
||||
}
|
||||
|
||||
# Group entities by domain and by device_class for sensors
|
||||
entity_groups = {}
|
||||
sensor_by_device_class = {}
|
||||
|
||||
for entity in entities:
|
||||
if isinstance(entity, dict) and "entity_id" in entity:
|
||||
entity_id = entity["entity_id"]
|
||||
# Get device_class from attributes if available
|
||||
device_class = entity.get("attributes", {}).get("device_class")
|
||||
else:
|
||||
entity_id = str(entity)
|
||||
device_class = None
|
||||
|
||||
domain = entity_id.split(".")[0]
|
||||
if domain not in entity_groups:
|
||||
entity_groups[domain] = []
|
||||
entity_groups[domain].append(entity_id)
|
||||
|
||||
# Categorize sensors by device_class
|
||||
if domain == "sensor" and device_class:
|
||||
if device_class not in sensor_by_device_class:
|
||||
sensor_by_device_class[device_class] = []
|
||||
sensor_by_device_class[device_class].append(entity_id)
|
||||
|
||||
# Create view with cards for each domain
|
||||
view_cards = []
|
||||
|
||||
# Lights
|
||||
if "light" in entity_groups:
|
||||
view_cards.append(
|
||||
{"type": "entities", "title": "Lights", "entities": entity_groups["light"]}
|
||||
)
|
||||
|
||||
# Climate - handle both climate entities and temperature/humidity sensors
|
||||
if "climate" in entity_groups:
|
||||
for climate_entity in entity_groups["climate"]:
|
||||
view_cards.append({"type": "thermostat", "entity": climate_entity})
|
||||
|
||||
# Temperature sensors - create history graphs for climate dashboards
|
||||
if "temperature" in sensor_by_device_class:
|
||||
temp_sensors = sensor_by_device_class["temperature"]
|
||||
# Add history graph for temperature visualization
|
||||
view_cards.append(
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Temperature History",
|
||||
"entities": temp_sensors,
|
||||
"hours_to_show": 24,
|
||||
}
|
||||
)
|
||||
# Add entity card for current values
|
||||
view_cards.append(
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Temperature Sensors",
|
||||
"entities": temp_sensors,
|
||||
}
|
||||
)
|
||||
|
||||
# Humidity sensors - create gauge cards for climate dashboards
|
||||
if "humidity" in sensor_by_device_class:
|
||||
humidity_sensors = sensor_by_device_class["humidity"]
|
||||
# Add history graph for humidity visualization
|
||||
view_cards.append(
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Humidity History",
|
||||
"entities": humidity_sensors,
|
||||
"hours_to_show": 24,
|
||||
}
|
||||
)
|
||||
# Add entity card for current values
|
||||
view_cards.append(
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Humidity Sensors",
|
||||
"entities": humidity_sensors,
|
||||
}
|
||||
)
|
||||
|
||||
# Media players
|
||||
if "media_player" in entity_groups:
|
||||
for media_entity in entity_groups["media_player"]:
|
||||
view_cards.append({"type": "media-control", "entity": media_entity})
|
||||
|
||||
# Security entities
|
||||
if "binary_sensor" in entity_groups:
|
||||
view_cards.append(
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Sensors",
|
||||
"entities": entity_groups["binary_sensor"],
|
||||
}
|
||||
)
|
||||
|
||||
if "alarm_control_panel" in entity_groups:
|
||||
for alarm_entity in entity_groups["alarm_control_panel"]:
|
||||
view_cards.append({"type": "alarm-panel", "entity": alarm_entity})
|
||||
|
||||
# Other Sensors (not temperature or humidity)
|
||||
if "sensor" in entity_groups:
|
||||
# Filter out temperature and humidity sensors that we already handled
|
||||
other_sensors = [
|
||||
s
|
||||
for s in entity_groups["sensor"]
|
||||
if s not in sensor_by_device_class.get("temperature", [])
|
||||
and s not in sensor_by_device_class.get("humidity", [])
|
||||
]
|
||||
if other_sensors:
|
||||
view_cards.append(
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Other Sensors",
|
||||
"entities": other_sensors[:10], # Limit to first 10
|
||||
}
|
||||
)
|
||||
|
||||
# Switches
|
||||
if "switch" in entity_groups:
|
||||
view_cards.append(
|
||||
{
|
||||
"type": "entities",
|
||||
"title": "Switches",
|
||||
"entities": entity_groups["switch"],
|
||||
}
|
||||
)
|
||||
|
||||
# Weather
|
||||
if "weather" in entity_groups:
|
||||
view_cards.append(
|
||||
{"type": "weather-forecast", "entity": entity_groups["weather"][0]}
|
||||
)
|
||||
|
||||
template["views"] = [{"title": "Overview", "cards": view_cards}]
|
||||
|
||||
return template
|
||||
@@ -1,8 +0,0 @@
|
||||
panel_custom:
|
||||
- name: AI Agent HA
|
||||
sidebar_title: AI Agent HA
|
||||
sidebar_icon: mdi:robot
|
||||
url_path: ai_agent_ha
|
||||
module_url: /local/ai_agent_ha/frontend/ai_agent_ha-panel.js
|
||||
trust_external: true
|
||||
require_admin: false
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"domain": "ai_agent_ha",
|
||||
"name": "AI Agent HA",
|
||||
"after_dependencies": [
|
||||
"history",
|
||||
"recorder",
|
||||
"lovelace"
|
||||
],
|
||||
"codeowners": [
|
||||
"@sbenodiz"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/sbenodiz/ai_agent_ha",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/sbenodiz/ai_agent_ha/issues",
|
||||
"requirements": [
|
||||
"aiohttp>=3.8.0"
|
||||
],
|
||||
"version": "1.14.1"
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
query:
|
||||
name: "Query AI Agent with Home Assistant context"
|
||||
description: "Run a custom AI prompt against your Home Assistant state dump."
|
||||
fields:
|
||||
prompt:
|
||||
description: "The question or instruction to send to AI model."
|
||||
example: "Turn on all the lights in the living room"
|
||||
debug:
|
||||
description: "Include a debug trace of the HA↔AI conversation (true/false)."
|
||||
example: true
|
||||
default: false
|
||||
provider:
|
||||
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
|
||||
example: "openai"
|
||||
default: "openai"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "openai"
|
||||
- "llama"
|
||||
- "gemini"
|
||||
- "openrouter"
|
||||
- "anthropic"
|
||||
- "alter"
|
||||
- "zai"
|
||||
- "local"
|
||||
|
||||
create_dashboard:
|
||||
name: "Create Dashboard via AI Agent"
|
||||
description: "Create a new Home Assistant dashboard using AI assistance."
|
||||
fields:
|
||||
dashboard_config:
|
||||
description: "The dashboard configuration as a JSON object."
|
||||
example: '{"title": "My Dashboard", "url_path": "my-dashboard", "views": []}'
|
||||
provider:
|
||||
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
|
||||
example: "openai"
|
||||
default: "openai"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "openai"
|
||||
- "llama"
|
||||
- "gemini"
|
||||
- "openrouter"
|
||||
- "anthropic"
|
||||
- "alter"
|
||||
- "zai"
|
||||
- "local"
|
||||
|
||||
create_automation:
|
||||
name: "Create Automation via AI Agent"
|
||||
description: "Create a new Home Assistant automation using AI assistance."
|
||||
fields:
|
||||
automation:
|
||||
description: "The automation configuration as a JSON object."
|
||||
example: '{"alias": "Turn off lights at 9 PM", "trigger": [{"platform": "time", "at": "21:00:00"}], "action": [{"service": "light.turn_off", "target": {"entity_id": "light.living_room"}}]}'
|
||||
provider:
|
||||
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
|
||||
example: "openai"
|
||||
default: "openai"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "openai"
|
||||
- "llama"
|
||||
- "gemini"
|
||||
- "openrouter"
|
||||
- "anthropic"
|
||||
- "alter"
|
||||
- "zai"
|
||||
- "local"
|
||||
|
||||
update_dashboard:
|
||||
name: "Update Dashboard via AI Agent"
|
||||
description: "Update an existing Home Assistant dashboard using AI assistance."
|
||||
fields:
|
||||
dashboard_url:
|
||||
description: "The URL path of dashboard to update."
|
||||
example: "my-dashboard"
|
||||
dashboard_config:
|
||||
description: "The updated dashboard configuration as a JSON object."
|
||||
example: '{"title": "Updated Dashboard", "views": []}'
|
||||
provider:
|
||||
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
|
||||
example: "openai"
|
||||
default: "openai"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "openai"
|
||||
- "llama"
|
||||
- "gemini"
|
||||
- "openrouter"
|
||||
- "anthropic"
|
||||
- "alter"
|
||||
- "zai"
|
||||
- "local"
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Choose AI Provider",
|
||||
"description": "Select your AI provider",
|
||||
"data": {
|
||||
"ai_provider": "AI Provider"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Choose your preferred AI provider"
|
||||
}
|
||||
},
|
||||
"configure": {
|
||||
"title": "Configure {provider}",
|
||||
"description": "Enter your {token_label} and optionally select a model",
|
||||
"data": {
|
||||
"llama_token": "Llama API Token",
|
||||
"openai_token": "OpenAI API Key",
|
||||
"gemini_token": "Google Gemini API Key",
|
||||
"openrouter_token": "OpenRouter API Key",
|
||||
"anthropic_token": "Anthropic API Key",
|
||||
"alter_token": "Alter API Key",
|
||||
"model": "Model (Optional)",
|
||||
"custom_model": "Custom Model (Optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Enter your Llama API token",
|
||||
"openai_token": "Enter your OpenAI API key",
|
||||
"gemini_token": "Enter your Google Gemini API key",
|
||||
"openrouter_token": "Enter your OpenRouter API key",
|
||||
"anthropic_token": "Enter your Anthropic API key",
|
||||
"alter_token": "Enter your Alter API key",
|
||||
"model": "Choose a predefined model or select 'Custom...' to enter your own",
|
||||
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "Invalid API key format",
|
||||
"unknown": "Unknown error occurred",
|
||||
"llama_token": "Llama API token is required",
|
||||
"openai_token": "OpenAI API key is required",
|
||||
"gemini_token": "Google Gemini API key is required",
|
||||
"openrouter_token": "OpenRouter API key is required",
|
||||
"anthropic_token": "Anthropic API key is required",
|
||||
"alter_token": "Alter API key is required"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "AI Agent HA is already configured"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "AI Provider Settings",
|
||||
"description": "Current provider: {current_provider}. Select a provider to configure",
|
||||
"data": {
|
||||
"ai_provider": "AI Provider"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Choose your preferred AI provider"
|
||||
}
|
||||
},
|
||||
"configure_options": {
|
||||
"title": "Configure {provider}",
|
||||
"description": "Update your {token_label} and model settings",
|
||||
"data": {
|
||||
"llama_token": "Llama API Token",
|
||||
"openai_token": "OpenAI API Key",
|
||||
"gemini_token": "Google Gemini API Key",
|
||||
"openrouter_token": "OpenRouter API Key",
|
||||
"anthropic_token": "Anthropic API Key",
|
||||
"alter_token": "Alter API Key",
|
||||
"model": "Model",
|
||||
"custom_model": "Custom Model (Optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Enter your Llama API token",
|
||||
"openai_token": "Enter your OpenAI API key",
|
||||
"gemini_token": "Enter your Google Gemini API key",
|
||||
"openrouter_token": "Enter your OpenRouter API key",
|
||||
"anthropic_token": "Enter your Anthropic API key",
|
||||
"alter_token": "Enter your Alter API key",
|
||||
"model": "Choose a model or select 'Custom...' to enter your own",
|
||||
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Agent d'IA HA - Seleccionar proveïdor",
|
||||
"description": "Tria el teu proveïdor d'IA",
|
||||
"data": {
|
||||
"ai_provider": "Proveïdor d'IA"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Selecciona el teu proveïdor d'IA preferit"
|
||||
}
|
||||
},
|
||||
"configure": {
|
||||
"title": "Agent d'IA HA - Configurar {provider}",
|
||||
"description": "Configura les teves credencials d'API i el model de {provider}",
|
||||
"data": {
|
||||
"llama_token": "Token de l'API de Llama",
|
||||
"openai_token": "Clau de l'API d'OpenAI",
|
||||
"gemini_token": "Clau de l'API de Google Gemini",
|
||||
"openrouter_token": "Clau de l'API d'OpenRouter",
|
||||
"model": "Model (Opcional)",
|
||||
"custom_model": "Nom del model personalitzat (Opcional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Introdueix el teu token de l'API de Llama",
|
||||
"openai_token": "Introdueix la teva clau de l'API d'OpenAI",
|
||||
"gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
|
||||
"openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
|
||||
"model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
|
||||
"custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "Clau o token d'API no vàlid",
|
||||
"llama_token": "Es requereix el token de l'API de Llama",
|
||||
"openai_token": "Es requereix la clau de l'API d'OpenAI",
|
||||
"gemini_token": "Es requereix la clau de l'API de Google Gemini",
|
||||
"openrouter_token": "Es requereix la clau de l'API d'OpenRouter",
|
||||
"unknown": "S'ha produït un error inesperat"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "L'Agent d'IA HA ja està configurat"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Agent d'IA HA - Seleccionar proveïdor",
|
||||
"description": "Tria el teu proveïdor d'IA (Actual: {current_provider})",
|
||||
"data": {
|
||||
"ai_provider": "Proveïdor d'IA"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Selecciona el teu proveïdor d'IA preferit"
|
||||
}
|
||||
},
|
||||
"configure_options": {
|
||||
"title": "Agent d'IA HA - Configurar {provider}",
|
||||
"description": "Actualitza les teves credencials d'API i el model de {provider}",
|
||||
"data": {
|
||||
"llama_token": "Token de l'API de Llama",
|
||||
"openai_token": "Clau de l'API d'OpenAI",
|
||||
"gemini_token": "Clau de l'API de Google Gemini",
|
||||
"openrouter_token": "Clau de l'API d'OpenRouter",
|
||||
"model": "Model (Opcional)",
|
||||
"custom_model": "Nom del model personalitzat (Opcional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Introdueix el teu token de l'API de Llama",
|
||||
"openai_token": "Introdueix la teva clau de l'API d'OpenAI",
|
||||
"gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
|
||||
"openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
|
||||
"model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
|
||||
"custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "KI-Anbieter wählen",
|
||||
"description": "Wähle deinen KI-Anbieter aus",
|
||||
"data": {
|
||||
"ai_provider": "KI-Anbieter"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
|
||||
}
|
||||
},
|
||||
"configure": {
|
||||
"title": "{provider} konfigurieren",
|
||||
"description": "Gib deinen {token_label} ein und wähle optional ein Model",
|
||||
"data": {
|
||||
"llama_token": "Llama API Token",
|
||||
"openai_token": "OpenAI API Key",
|
||||
"gemini_token": "Google Gemini API Key",
|
||||
"openrouter_token": "OpenRouter API Key",
|
||||
"anthropic_token": "Anthropic API Key",
|
||||
"model": "Model (Optional)",
|
||||
"custom_model": "Custom Model (Optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Gib deinen Llama API Token ein",
|
||||
"openai_token": "Gib deinen OpenAI API Key ein",
|
||||
"gemini_token": "Gib deinen Google Gemini API Key ein",
|
||||
"openrouter_token": "Gib deinen OpenRouter API Key ein",
|
||||
"anthropic_token": "Gib deinen Anthropic API Key ein",
|
||||
"model": "Wähle ein vordefiniertes Model oder 'Custom...' zum manuellen Eintrag",
|
||||
"custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "Ungültiges API-Key-Format",
|
||||
"unknown": "Unbekannter Fehler aufgetreten",
|
||||
"llama_token": "Llama API Token ist erforderlich",
|
||||
"openai_token": "OpenAI API Key ist erforderlich",
|
||||
"gemini_token": "Google Gemini API Key ist erforderlich",
|
||||
"openrouter_token": "OpenRouter API Key ist erforderlich",
|
||||
"anthropic_token": "Anthropic API Key ist erforderlich"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "AI Agent HA ist bereits konfiguriert"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Einstellungen für KI-Anbieter",
|
||||
"description": "Aktueller Anbieter: {current_provider}. Wähle einen Anbieter zur Konfiguration",
|
||||
"data": {
|
||||
"ai_provider": "KI-Anbieter"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
|
||||
}
|
||||
},
|
||||
"configure_options": {
|
||||
"title": "{provider} konfigurieren",
|
||||
"description": "Aktualisiere deinen {token_label} und Model-Einstellungen",
|
||||
"data": {
|
||||
"llama_token": "Llama API Token",
|
||||
"openai_token": "OpenAI API Key",
|
||||
"gemini_token": "Google Gemini API Key",
|
||||
"openrouter_token": "OpenRouter API Key",
|
||||
"anthropic_token": "Anthropic API Key",
|
||||
"model": "Model",
|
||||
"custom_model": "Custom Model (Optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Gib deinen Llama API Token ein",
|
||||
"openai_token": "Gib deinen OpenAI API Key ein",
|
||||
"gemini_token": "Gib deinen Google Gemini API Key ein",
|
||||
"openrouter_token": "Gib deinen OpenRouter API Key ein",
|
||||
"anthropic_token": "Gib deinen Anthropic API Key ein",
|
||||
"model": "Wähle ein Model oder 'Custom...' zum manuellen Eintrag",
|
||||
"custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Choose AI Provider",
|
||||
"description": "Select your AI provider",
|
||||
"data": {
|
||||
"ai_provider": "AI Provider"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Choose your preferred AI provider"
|
||||
}
|
||||
},
|
||||
"configure": {
|
||||
"title": "Configure {provider}",
|
||||
"description": "Enter your {token_label} and optionally select a model",
|
||||
"data": {
|
||||
"llama_token": "Llama API Token",
|
||||
"openai_token": "OpenAI API Key",
|
||||
"gemini_token": "Google Gemini API Key",
|
||||
"openrouter_token": "OpenRouter API Key",
|
||||
"anthropic_token": "Anthropic API Key",
|
||||
"alter_token": "Alter API Key",
|
||||
"model": "Model (Optional)",
|
||||
"custom_model": "Custom Model (Optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Enter your Llama API token",
|
||||
"openai_token": "Enter your OpenAI API key",
|
||||
"gemini_token": "Enter your Google Gemini API key",
|
||||
"openrouter_token": "Enter your OpenRouter API key",
|
||||
"anthropic_token": "Enter your Anthropic API key",
|
||||
"alter_token": "Enter your Alter API key",
|
||||
"model": "Choose a predefined model or select 'Custom...' to enter your own",
|
||||
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "Invalid API key format",
|
||||
"unknown": "Unknown error occurred",
|
||||
"llama_token": "Llama API token is required",
|
||||
"openai_token": "OpenAI API key is required",
|
||||
"gemini_token": "Google Gemini API key is required",
|
||||
"openrouter_token": "OpenRouter API key is required",
|
||||
"anthropic_token": "Anthropic API key is required",
|
||||
"alter_token": "Alter API key is required"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "AI Agent HA is already configured"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "AI Provider Settings",
|
||||
"description": "Current provider: {current_provider}. Select a provider to configure",
|
||||
"data": {
|
||||
"ai_provider": "AI Provider"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Choose your preferred AI provider"
|
||||
}
|
||||
},
|
||||
"configure_options": {
|
||||
"title": "Configure {provider}",
|
||||
"description": "Update your {token_label} and model settings",
|
||||
"data": {
|
||||
"llama_token": "Llama API Token",
|
||||
"openai_token": "OpenAI API Key",
|
||||
"gemini_token": "Google Gemini API Key",
|
||||
"openrouter_token": "OpenRouter API Key",
|
||||
"anthropic_token": "Anthropic API Key",
|
||||
"alter_token": "Alter API Key",
|
||||
"model": "Model",
|
||||
"custom_model": "Custom Model (Optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Enter your Llama API token",
|
||||
"openai_token": "Enter your OpenAI API key",
|
||||
"gemini_token": "Enter your Google Gemini API key",
|
||||
"openrouter_token": "Enter your OpenRouter API key",
|
||||
"anthropic_token": "Enter your Anthropic API key",
|
||||
"alter_token": "Enter your Alter API key",
|
||||
"model": "Choose a model or select 'Custom...' to enter your own",
|
||||
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "AI Agent HA - Select Provider",
|
||||
"description": "Choose your AI provider",
|
||||
"data": {
|
||||
"ai_provider": "AI Provider"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Select your preferred AI provider"
|
||||
}
|
||||
},
|
||||
"configure": {
|
||||
"title": "AI Agent HA - Configure {provider}",
|
||||
"description": "Configure your {provider} API credentials and model",
|
||||
"data": {
|
||||
"llama_token": "Llama API Token",
|
||||
"openai_token": "OpenAI API Key",
|
||||
"gemini_token": "Google Gemini API Key",
|
||||
"openrouter_token": "OpenRouter API Key",
|
||||
"model": "Model (Optional)",
|
||||
"custom_model": "Custom Model Name (Optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Enter your Llama API token",
|
||||
"openai_token": "Enter your OpenAI API key",
|
||||
"gemini_token": "Enter your Google Gemini API key",
|
||||
"openrouter_token": "Enter your OpenRouter API key",
|
||||
"model": "Choose a predefined model or select 'Custom...' to use a custom model",
|
||||
"custom_model": "Enter a custom model name (overrides the dropdown selection above)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "Invalid API key or token",
|
||||
"llama_token": "Llama API token is required",
|
||||
"openai_token": "OpenAI API key is required",
|
||||
"gemini_token": "Google Gemini API key is required",
|
||||
"openrouter_token": "OpenRouter API key is required",
|
||||
"unknown": "Unexpected error occurred"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "AI Agent HA is already configured"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "AI Agent HA - Select Provider",
|
||||
"description": "Choose your AI provider (Current: {current_provider})",
|
||||
"data": {
|
||||
"ai_provider": "AI Provider"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Select your preferred AI provider"
|
||||
}
|
||||
},
|
||||
"configure_options": {
|
||||
"title": "AI Agent HA - Configure {provider}",
|
||||
"description": "Update your {provider} API credentials and model",
|
||||
"data": {
|
||||
"llama_token": "Llama API Token",
|
||||
"openai_token": "OpenAI API Key",
|
||||
"gemini_token": "Google Gemini API Key",
|
||||
"openrouter_token": "OpenRouter API Key",
|
||||
"model": "Model (Optional)",
|
||||
"custom_model": "Custom Model Name (Optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Enter your Llama API token",
|
||||
"openai_token": "Enter your OpenAI API key",
|
||||
"gemini_token": "Enter your Google Gemini API key",
|
||||
"openrouter_token": "Enter your OpenRouter API key",
|
||||
"model": "Choose a predefined model or select 'Custom...' to use a custom model",
|
||||
"custom_model": "Enter a custom model name (overrides the dropdown selection above)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Agente de IA HA - Seleccionar proveedor",
|
||||
"description": "Elige tu proveedor de IA",
|
||||
"data": {
|
||||
"ai_provider": "Proveedor de IA"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Selecciona tu proveedor de IA preferido"
|
||||
}
|
||||
},
|
||||
"configure": {
|
||||
"title": "Agente de IA HA - Configurar {provider}",
|
||||
"description": "Configura tus credenciales de API y modelo de {provider}",
|
||||
"data": {
|
||||
"llama_token": "Token de la API de Llama",
|
||||
"openai_token": "Clave de la API de OpenAI",
|
||||
"gemini_token": "Clave de la API de Google Gemini",
|
||||
"openrouter_token": "Clave de la API de OpenRouter",
|
||||
"model": "Modelo (Opcional)",
|
||||
"custom_model": "Nombre de modelo personalizado (Opcional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Introduce tu token de la API de Llama",
|
||||
"openai_token": "Introduce tu clave de la API de OpenAI",
|
||||
"gemini_token": "Introduce tu clave de la API de Google Gemini",
|
||||
"openrouter_token": "Introduce tu clave de la API de OpenRouter",
|
||||
"model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
|
||||
"custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "Clave o token de API no válido",
|
||||
"llama_token": "Se requiere el token de la API de Llama",
|
||||
"openai_token": "Se requiere la clave de la API de OpenAI",
|
||||
"gemini_token": "Se requiere la clave de la API de Google Gemini",
|
||||
"openrouter_token": "Se requiere la clave de la API de OpenRouter",
|
||||
"unknown": "Ha ocurrido un error inesperado"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "El Agente de IA HA ya está configurado"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Agente de IA HA - Seleccionar proveedor",
|
||||
"description": "Elige tu proveedor de IA (Actual: {current_provider})",
|
||||
"data": {
|
||||
"ai_provider": "Proveedor de IA"
|
||||
},
|
||||
"data_description": {
|
||||
"ai_provider": "Selecciona tu proveedor de IA preferido"
|
||||
}
|
||||
},
|
||||
"configure_options": {
|
||||
"title": "Agente de IA HA - Configurar {provider}",
|
||||
"description": "Actualiza tus credenciales de API y modelo de {provider}",
|
||||
"data": {
|
||||
"llama_token": "Token de la API de Llama",
|
||||
"openai_token": "Clave de la API de OpenAI",
|
||||
"gemini_token": "Clave de la API de Google Gemini",
|
||||
"openrouter_token": "Clave de la API de OpenRouter",
|
||||
"model": "Modelo (Opcional)",
|
||||
"custom_model": "Nombre de modelo personalizado (Opcional)"
|
||||
},
|
||||
"data_description": {
|
||||
"llama_token": "Introduce tu token de la API de Llama",
|
||||
"openai_token": "Introduce tu clave de la API de OpenAI",
|
||||
"gemini_token": "Introduce tu clave de la API de Google Gemini",
|
||||
"openrouter_token": "Introduce tu clave de la API de OpenRouter",
|
||||
"model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
|
||||
"custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,6 +430,11 @@ class AlarmoCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
return
|
||||
|
||||
if action == const.EVENT_ACTION_DISARM:
|
||||
_LOGGER.info("Received request for disarming")
|
||||
await alarm_entity.async_alarm_disarm(None, skip_code=True)
|
||||
return
|
||||
|
||||
arm_mode = (
|
||||
alarm_entity._revert_state
|
||||
if alarm_entity._revert_state in const.ARM_MODES
|
||||
@@ -452,9 +457,6 @@ class AlarmoCoordinator(DataUpdateCoordinator):
|
||||
elif action == const.EVENT_ACTION_RETRY_ARM:
|
||||
_LOGGER.info("Received request for retry arming")
|
||||
await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
|
||||
elif action == const.EVENT_ACTION_DISARM:
|
||||
_LOGGER.info("Received request for disarming")
|
||||
await alarm_entity.async_alarm_disarm(None, skip_code=True)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Received request for arming with mode %s",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -323,8 +323,11 @@ class AlarmoBaseEntity(AlarmControlPanelEntity, RestoreEntity):
|
||||
async def _validate_code(self, code, to_state): # noqa PLR0911
|
||||
"""Validate code and user permissions for a requested state change.
|
||||
|
||||
Returns a (success, error_event) tuple. When success is True,
|
||||
error_event is None.
|
||||
Returns a (success, info) tuple.
|
||||
When validation is successful, success is True, otherwise False.
|
||||
When success is True, info is the user data
|
||||
(or None if no user/code was needed).
|
||||
When success is False, info is the error event.
|
||||
"""
|
||||
# check bypass rules
|
||||
if (
|
||||
@@ -404,7 +407,7 @@ class AlarmoBaseEntity(AlarmControlPanelEntity, RestoreEntity):
|
||||
|
||||
# success
|
||||
self._changed_by = user[ATTR_NAME]
|
||||
return True, None
|
||||
return True, user
|
||||
|
||||
async def async_service_disarm_handler(self, code, context_id=None):
|
||||
"""Handle external disarm request from alarmo.disarm service."""
|
||||
|
||||
@@ -44,6 +44,8 @@ def validate_area(trigger, area_id, hass):
|
||||
return False
|
||||
elif trigger[const.ATTR_AREA]:
|
||||
return trigger[const.ATTR_AREA] == area_id
|
||||
elif area_id and hass.data[const.DOMAIN].get("master"):
|
||||
return False
|
||||
elif len(hass.data[const.DOMAIN]["areas"]) == 1:
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntityFeature,
|
||||
)
|
||||
|
||||
VERSION = "1.10.18"
|
||||
VERSION = "1.10.19"
|
||||
NAME = "Alarmo"
|
||||
MANUFACTURER = "@nielsfaber"
|
||||
|
||||
|
||||
+448
-448
File diff suppressed because one or more lines are too long
@@ -17,5 +17,5 @@
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/nielsfaber/alarmo/issues",
|
||||
"requirements": [],
|
||||
"version": "1.10.18"
|
||||
"version": "1.10.19"
|
||||
}
|
||||
@@ -130,7 +130,11 @@ class SensorHandler:
|
||||
|
||||
def __init__(self, hass: HomeAssistant):
|
||||
"""Initialize the sensor handler."""
|
||||
self._config = None
|
||||
# The sensor config is only loaded once HA has finished starting (see
|
||||
# _setup_sensor_listeners below). Until then this must be an empty dict
|
||||
# rather than None: restoring a persisted alarm state can call into
|
||||
# active_sensors_for_alarm_state() before that point.
|
||||
self._config = {}
|
||||
self.hass = hass
|
||||
self._state_listener = None
|
||||
self._subscriptions = []
|
||||
@@ -144,9 +148,10 @@ class SensorHandler:
|
||||
@callback
|
||||
def async_update_sensor_config():
|
||||
"""Sensor config updated, reload the configuration."""
|
||||
self._config = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_sensors()
|
||||
self._config = (
|
||||
self.hass.data[const.DOMAIN]["coordinator"].store.async_get_sensors()
|
||||
or {}
|
||||
)
|
||||
self._groups = self.hass.data[const.DOMAIN][
|
||||
"coordinator"
|
||||
].store.async_get_sensor_groups()
|
||||
@@ -377,8 +382,11 @@ class SensorHandler:
|
||||
new_state = parse_sensor_state(event.data["new_state"])
|
||||
sensor_config = self._config[entity]
|
||||
if old_state == STATE_UNKNOWN:
|
||||
# sensor is unknown at startup,
|
||||
# state which comes after is considered as initial state
|
||||
if new_state not in (STATE_OPEN, STATE_UNAVAILABLE) or (
|
||||
sensor_config[ATTR_ALLOW_OPEN] and new_state == STATE_OPEN
|
||||
):
|
||||
# transition to a safe state, or to open while the sensor is
|
||||
# allowed to be open — treat as initial state
|
||||
_LOGGER.debug(
|
||||
"Initial state for %s is %s",
|
||||
entity,
|
||||
@@ -386,6 +394,16 @@ class SensorHandler:
|
||||
)
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
else:
|
||||
# transition to a violation state — do not treat as initial,
|
||||
# proceed through normal trigger evaluation
|
||||
_LOGGER.debug(
|
||||
"Sensor %s recovered from unknown to %s while alarm is %s, "
|
||||
"evaluating as live state change",
|
||||
entity,
|
||||
new_state,
|
||||
self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]].state,
|
||||
)
|
||||
if old_state == new_state:
|
||||
# not a state change - ignore
|
||||
return
|
||||
@@ -740,6 +758,13 @@ class SensorHandler:
|
||||
# Skip unknown sensors - they'll be handled when they become known
|
||||
continue
|
||||
|
||||
if sensor_config[ATTR_ALLOW_OPEN] and sensor_state == STATE_OPEN:
|
||||
_LOGGER.debug(
|
||||
"Sensor %s is open with allow_open, skipping startup eval",
|
||||
entity_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Check if sensor state is allowed in current alarm state
|
||||
res = sensor_state_allowed(sensor_state, sensor_config, alarm_entity.state)
|
||||
|
||||
|
||||
@@ -381,10 +381,10 @@ class AlarmoStorage:
|
||||
for area in data["areas"]:
|
||||
modes = {
|
||||
mode: ModeEntry(
|
||||
enabled=config["enabled"],
|
||||
exit_time=config["exit_time"],
|
||||
entry_time=config["entry_time"],
|
||||
trigger_time=config["trigger_time"],
|
||||
enabled=config.get("enabled", False),
|
||||
exit_time=config.get("exit_time", None),
|
||||
entry_time=config.get("entry_time", None),
|
||||
trigger_time=config.get("trigger_time", None),
|
||||
)
|
||||
for (mode, config) in area["modes"].items()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
|
||||
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_PREVIOUS_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
|
||||
from .store import SlideshowStore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -392,6 +392,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if cam:
|
||||
await cam.async_force_next()
|
||||
|
||||
async def _previous_slide(call) -> None:
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
||||
if not entry_id:
|
||||
return
|
||||
data = hass.data.get(DOMAIN, {}).get(entry_id)
|
||||
if not data:
|
||||
return
|
||||
cam = data.get("camera")
|
||||
if cam:
|
||||
await cam.async_force_prev()
|
||||
|
||||
async def _refresh_album(call) -> None:
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
||||
if not entry_id:
|
||||
@@ -406,6 +417,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_NEXT_SLIDE):
|
||||
hass.services.async_register(DOMAIN, SERVICE_NEXT_SLIDE, _next_slide)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_PREVIOUS_SLIDE):
|
||||
hass.services.async_register(DOMAIN, SERVICE_PREVIOUS_SLIDE, _previous_slide)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
|
||||
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
|
||||
from .const import DOMAIN, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
|
||||
from .coordinator import AlbumCoordinator
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
||||
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
|
||||
async_add_entities(
|
||||
[
|
||||
PreviousSlideButton(hass, entry, coordinator),
|
||||
NextSlideButton(hass, entry, coordinator),
|
||||
RefreshAlbumButton(hass, entry, coordinator),
|
||||
]
|
||||
@@ -36,6 +37,19 @@ class _BaseButton(ButtonEntity):
|
||||
}
|
||||
|
||||
|
||||
class PreviousSlideButton(_BaseButton):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(hass, entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_previous_button"
|
||||
self._attr_name = "Previous slide"
|
||||
self._attr_icon = "mdi:skip-previous"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
camera = self.hass.data[DOMAIN][self.entry.entry_id].get("camera")
|
||||
if camera is not None:
|
||||
await camera.async_force_prev()
|
||||
|
||||
|
||||
class NextSlideButton(_BaseButton):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
super().__init__(hass, entry, coordinator)
|
||||
@@ -44,12 +58,9 @@ class NextSlideButton(_BaseButton):
|
||||
self._attr_icon = "mdi:skip-next"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_NEXT_SLIDE,
|
||||
{ATTR_ENTRY_ID: self.entry.entry_id},
|
||||
blocking=False,
|
||||
)
|
||||
camera = self.hass.data[DOMAIN][self.entry.entry_id].get("camera")
|
||||
if camera is not None:
|
||||
await camera.async_force_next()
|
||||
|
||||
|
||||
class RefreshAlbumButton(_BaseButton):
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from PIL import Image
|
||||
@@ -46,18 +49,44 @@ _ACCEPTED_IMAGE_PREFIX = ("image/",)
|
||||
_PAIR_SEARCH_LIMIT = 12
|
||||
_SKIP_SEARCH_LIMIT = 30
|
||||
|
||||
_MAX_RENDER_ATTEMPTS = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _NavigationCursor:
|
||||
"""All mutable ordering state needed to render the following slide."""
|
||||
|
||||
index: int
|
||||
random_order: tuple[int, ...]
|
||||
random_pos: int
|
||||
recent_urls: tuple[str, ...]
|
||||
rng_state: Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RenderedFrame:
|
||||
"""A complete slide that can be displayed without any further work."""
|
||||
|
||||
data: bytes
|
||||
cursor: _NavigationCursor
|
||||
meta: dict
|
||||
|
||||
|
||||
def _ts_to_iso(ts_ms: int | None) -> str | None:
|
||||
"""Convert epoch milliseconds to an ISO-8601 string in UTC, or None."""
|
||||
if not isinstance(ts_ms, int):
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat()
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
"""Return the current time as an ISO-8601 UTC string."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class _DownloadCache:
|
||||
"""Byte-budget LRU cache for downloaded image data, O(1) per operation."""
|
||||
|
||||
@@ -155,22 +184,43 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._frame_id: int = 0
|
||||
|
||||
self._interrupt_event: asyncio.Event = asyncio.Event()
|
||||
self._force_next: bool = False
|
||||
# Navigation runs directly in the button/service coroutine. The lock
|
||||
# serialises rapid presses while buffered swaps remain independent of
|
||||
# the background timer loop.
|
||||
self._navigation_lock: asyncio.Lock = asyncio.Lock()
|
||||
self._navigation_pending: int = 0
|
||||
# Rendered timeline around the current frame. Previous frames and future
|
||||
# frames are final encoded JPEGs, so both navigation directions are an
|
||||
# O(1) deque swap. The future deque is replenished in the background.
|
||||
self._current_frame: _RenderedFrame | None = None
|
||||
self._previous_frames: deque[_RenderedFrame] = deque()
|
||||
self._next_frames: deque[_RenderedFrame] = deque()
|
||||
self._timeline_generation: int = 0
|
||||
self._timeline_dirty: bool = False
|
||||
self._next_ready_event: asyncio.Event = asyncio.Event()
|
||||
self._preload_task: asyncio.Task | None = None
|
||||
# Visible navigation diagnostics. These are state attributes rather
|
||||
# than debug-only log messages so they remain observable when Home
|
||||
# Assistant's UI filters debug logs.
|
||||
self._last_nav_direction: str | None = None
|
||||
self._last_nav_requested_at: str | None = None
|
||||
self._last_nav_started_at: str | None = None
|
||||
self._last_nav_committed_at: str | None = None
|
||||
self._last_nav_outcome: str | None = None
|
||||
self._last_nav_error: str | None = None
|
||||
self._consecutive_failures: int = 0
|
||||
self._render_task: asyncio.Task | None = None
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
self._effective_cache = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
self._invalidate_timeline()
|
||||
|
||||
coordinator.async_add_listener(_on_coordinator_update)
|
||||
|
||||
def _on_store_change() -> None:
|
||||
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
|
||||
self._effective_cache = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
self._invalidate_timeline()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
|
||||
@@ -193,12 +243,19 @@ class AlbumSlideshowCamera(Camera):
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
preload_task = self._preload_task
|
||||
self._cancel_preload()
|
||||
if self._render_task is not None:
|
||||
self._render_task.cancel()
|
||||
try:
|
||||
await self._render_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if preload_task is not None:
|
||||
try:
|
||||
await preload_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
@@ -258,6 +315,19 @@ class AlbumSlideshowCamera(Camera):
|
||||
"pair_divider_px": int(self.store.pair_divider_px),
|
||||
"pair_divider_color": self.store.pair_divider_color,
|
||||
"frame_id": self._frame_id,
|
||||
"navigation_buffer_size": self._buffer_depth,
|
||||
"previous_frames_cached": len(self._previous_frames),
|
||||
"next_frames_preloaded": len(self._next_frames),
|
||||
"navigation_preloading": bool(
|
||||
self._preload_task is not None and not self._preload_task.done()
|
||||
),
|
||||
"navigation_queue_size": self._navigation_pending,
|
||||
"last_navigation_direction": self._last_nav_direction,
|
||||
"last_navigation_requested_at": self._last_nav_requested_at,
|
||||
"last_navigation_started_at": self._last_nav_started_at,
|
||||
"last_navigation_committed_at": self._last_nav_committed_at,
|
||||
"last_navigation_outcome": self._last_nav_outcome,
|
||||
"last_navigation_error": self._last_nav_error,
|
||||
"pagination_debug": data.get("pagination_debug"),
|
||||
}
|
||||
|
||||
@@ -331,10 +401,53 @@ class AlbumSlideshowCamera(Camera):
|
||||
return ordered
|
||||
|
||||
async def async_force_next(self) -> None:
|
||||
self._force_next = True
|
||||
"""Display the next buffered slide immediately."""
|
||||
await self._async_navigate(1)
|
||||
|
||||
async def async_force_prev(self) -> None:
|
||||
"""Display the previous retained slide immediately."""
|
||||
await self._async_navigate(-1)
|
||||
|
||||
async def _async_navigate(self, direction: int) -> None:
|
||||
"""Serialise a manual navigation request and execute it directly."""
|
||||
direction_name = "next" if direction > 0 else "previous"
|
||||
self._navigation_pending += 1
|
||||
self._last_nav_direction = direction_name
|
||||
self._last_nav_requested_at = _utc_now_iso()
|
||||
self._last_nav_outcome = "pending"
|
||||
self._last_nav_error = None
|
||||
# Wake the timer loop so the manual frame starts a fresh interval.
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
try:
|
||||
async with self._navigation_lock:
|
||||
self._last_nav_started_at = _utc_now_iso()
|
||||
if self._timeline_dirty:
|
||||
await self._rebuild_current_frame()
|
||||
changed = (
|
||||
await self._show_next_frame()
|
||||
if direction > 0
|
||||
else await self._show_previous_frame()
|
||||
)
|
||||
if changed:
|
||||
self._last_nav_committed_at = _utc_now_iso()
|
||||
self._last_nav_outcome = "displayed"
|
||||
else:
|
||||
self._last_nav_outcome = "not_available"
|
||||
except Exception as err:
|
||||
self._last_nav_outcome = "error"
|
||||
self._last_nav_error = str(err)
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow %s: manual %s navigation failed: %s",
|
||||
self.entry.title,
|
||||
direction_name,
|
||||
err,
|
||||
)
|
||||
finally:
|
||||
self._navigation_pending -= 1
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_force_refresh(self) -> None:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -373,123 +486,387 @@ class AlbumSlideshowCamera(Camera):
|
||||
async def async_handle_async_mjpeg_stream(self, request):
|
||||
return await self.handle_async_mjpeg_stream(request)
|
||||
|
||||
async def _wait_or_interrupt(self, timeout: float) -> bool:
|
||||
"""Wait up to ``timeout`` seconds, returning True if interrupted.
|
||||
@property
|
||||
def _buffer_depth(self) -> int:
|
||||
"""Configured number of rendered frames retained in each direction."""
|
||||
return min(10, max(0, int(self.store.navigation_buffer_size)))
|
||||
|
||||
Does NOT clear ``_interrupt_event`` - the render loop clears it once
|
||||
per cycle, before rendering, so a signal that arrives while we're
|
||||
rendering (a "next slide" press, a coordinator/store change) survives
|
||||
until we get here instead of being wiped.
|
||||
def _capture_cursor(self, source=None) -> _NavigationCursor:
|
||||
"""Snapshot ordering state from this camera or a private renderer."""
|
||||
source = source or self
|
||||
return _NavigationCursor(
|
||||
index=int(source._index),
|
||||
random_order=tuple(source._random_order),
|
||||
random_pos=int(source._random_pos),
|
||||
recent_urls=tuple(source._recent_urls),
|
||||
rng_state=source._rng.getstate(),
|
||||
)
|
||||
|
||||
A force-next that's already pending is honored immediately without
|
||||
sleeping, so a press that landed during the last render (or right at
|
||||
the loop boundary) isn't held until the full slide interval elapses.
|
||||
def _make_renderer(self, cursor: _NavigationCursor):
|
||||
"""Create an isolated render context sharing only immutable services/cache.
|
||||
|
||||
Composition helpers historically operate on ``self._index`` and random
|
||||
ordering fields. A private camera-shaped context lets background
|
||||
preloading reuse those mature helpers without ever mutating the live
|
||||
entity's current frame or navigation cursor.
|
||||
"""
|
||||
if self._force_next:
|
||||
renderer = AlbumSlideshowCamera.__new__(AlbumSlideshowCamera)
|
||||
renderer.hass = self.hass
|
||||
renderer.entry = self.entry
|
||||
renderer.coordinator = self.coordinator
|
||||
renderer.store = self.store
|
||||
renderer._download_cache = self._download_cache
|
||||
renderer._index = cursor.index
|
||||
renderer._random_order = list(cursor.random_order)
|
||||
renderer._random_pos = cursor.random_pos
|
||||
renderer._recent_urls = list(cursor.recent_urls)
|
||||
renderer._rng = random.Random()
|
||||
renderer._rng.setstate(cursor.rng_state)
|
||||
return renderer
|
||||
|
||||
async def _render_available_frame(
|
||||
self,
|
||||
cursor: _NavigationCursor,
|
||||
items: list[MediaItem],
|
||||
*,
|
||||
advance: bool,
|
||||
) -> _RenderedFrame:
|
||||
"""Render the current or next usable slide from ``cursor``.
|
||||
|
||||
Broken candidates are skipped, matching the old loop's retry behavior.
|
||||
All state mutations occur on a private renderer. The returned JPEG and
|
||||
cursor are therefore safe to place in the future buffer.
|
||||
"""
|
||||
if not items:
|
||||
raise RuntimeError("No media available")
|
||||
|
||||
attempts = min(len(items), _MAX_RENDER_ATTEMPTS)
|
||||
last_error: Exception | None = None
|
||||
should_advance = advance
|
||||
|
||||
for _ in range(attempts):
|
||||
renderer = self._make_renderer(cursor)
|
||||
renderer._index %= len(items)
|
||||
if should_advance:
|
||||
renderer._do_advance(len(items), items)
|
||||
|
||||
composed: Image.Image | None = None
|
||||
try:
|
||||
async with self._compose_semaphore:
|
||||
composed, meta = await renderer._compose_for_index(items)
|
||||
cursor = self._capture_cursor(renderer)
|
||||
if composed is None:
|
||||
raise RuntimeError("Image composition returned no frame")
|
||||
encoded = await self.hass.async_add_executor_job(
|
||||
ip.encode_image, composed
|
||||
)
|
||||
return _RenderedFrame(encoded, cursor, meta or {})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
last_error = err
|
||||
cursor = self._capture_cursor(renderer)
|
||||
should_advance = True
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow %s: skipping unrenderable buffered slide: %s",
|
||||
self.entry.title,
|
||||
err,
|
||||
)
|
||||
finally:
|
||||
ip.safe_close(composed)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Could not render a usable slide after {attempts} attempts: {last_error}"
|
||||
)
|
||||
|
||||
def _apply_frame(self, frame: _RenderedFrame) -> None:
|
||||
"""Make a rendered frame current and publish it to Home Assistant."""
|
||||
self._current_frame = frame
|
||||
self._framebuffer = frame.data
|
||||
self.store.last_frame = frame.data
|
||||
self._index = frame.cursor.index
|
||||
self._random_order = list(frame.cursor.random_order)
|
||||
self._random_pos = frame.cursor.random_pos
|
||||
self._recent_urls = list(frame.cursor.recent_urls)
|
||||
self._rng.setstate(frame.cursor.rng_state)
|
||||
|
||||
meta = frame.meta
|
||||
self._last_is_portrait = meta.get("is_portrait")
|
||||
self._last_captured_at_pair = meta.get("captured_at_pair")
|
||||
self._last_pair_frames = meta.get("pair_frames")
|
||||
self._last_pair_orientation = meta.get("pair_orientation")
|
||||
self._frame_id += 1
|
||||
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow %s: displayed buffered frame_id=%d index=%d "
|
||||
"previous=%d next=%d",
|
||||
self.entry.title,
|
||||
self._frame_id,
|
||||
self._index,
|
||||
len(self._previous_frames),
|
||||
len(self._next_frames),
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _trim_timeline(self) -> None:
|
||||
"""Enforce the configured frame count on both sides of current."""
|
||||
depth = self._buffer_depth
|
||||
while len(self._previous_frames) > depth:
|
||||
self._previous_frames.popleft()
|
||||
while len(self._next_frames) > depth:
|
||||
self._next_frames.pop()
|
||||
|
||||
def _cancel_preload(self) -> None:
|
||||
task = self._preload_task
|
||||
self._preload_task = None
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
self._next_ready_event.set()
|
||||
|
||||
def _invalidate_timeline(self) -> None:
|
||||
"""Drop frames rendered from stale media/settings and wake the loop."""
|
||||
self._timeline_generation += 1
|
||||
self._timeline_dirty = True
|
||||
self._previous_frames.clear()
|
||||
self._next_frames.clear()
|
||||
self._cancel_preload()
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _schedule_preload(self) -> None:
|
||||
"""Start the single per-camera worker that fills future frames."""
|
||||
self._trim_timeline()
|
||||
if (
|
||||
self._buffer_depth <= 0
|
||||
or self._current_frame is None
|
||||
or len(self._next_frames) >= self._buffer_depth
|
||||
):
|
||||
return
|
||||
if self._preload_task is not None and not self._preload_task.done():
|
||||
return
|
||||
|
||||
generation = self._timeline_generation
|
||||
self._preload_task = self.hass.async_create_background_task(
|
||||
self._preload_loop(generation),
|
||||
name="album_slideshow_preload",
|
||||
)
|
||||
|
||||
async def _preload_loop(self, generation: int) -> None:
|
||||
"""Render future slides sequentially until the configured buffer is full."""
|
||||
this_task = asyncio.current_task()
|
||||
try:
|
||||
while generation == self._timeline_generation:
|
||||
self._trim_timeline()
|
||||
if len(self._next_frames) >= self._buffer_depth:
|
||||
return
|
||||
|
||||
base = self._next_frames[-1] if self._next_frames else self._current_frame
|
||||
if base is None:
|
||||
return
|
||||
items = self._effective_items()
|
||||
if not items:
|
||||
return
|
||||
|
||||
frame = await self._render_available_frame(
|
||||
base.cursor,
|
||||
items,
|
||||
advance=True,
|
||||
)
|
||||
if generation != self._timeline_generation:
|
||||
return
|
||||
|
||||
# Navigation may have moved the base from future to current or
|
||||
# vice versa while rendering. It is still valid if it remains
|
||||
# the last frame in the known timeline.
|
||||
current_tail = (
|
||||
self._next_frames[-1]
|
||||
if self._next_frames
|
||||
else self._current_frame
|
||||
)
|
||||
if current_tail is not base:
|
||||
continue
|
||||
if len(self._next_frames) < self._buffer_depth:
|
||||
self._next_frames.append(frame)
|
||||
self._next_ready_event.set()
|
||||
self.async_write_ha_state()
|
||||
await asyncio.sleep(0)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow %s: could not fill navigation buffer: %s",
|
||||
self.entry.title,
|
||||
err,
|
||||
)
|
||||
finally:
|
||||
if self._preload_task is this_task:
|
||||
self._preload_task = None
|
||||
self._next_ready_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _await_preloaded_frame(self) -> bool:
|
||||
"""Wait for the in-flight worker's first frame, without waiting for all X."""
|
||||
if self._next_frames:
|
||||
return True
|
||||
if self._buffer_depth <= 0:
|
||||
return False
|
||||
|
||||
self._schedule_preload()
|
||||
while not self._next_frames:
|
||||
task = self._preload_task
|
||||
if task is None or task.done():
|
||||
return False
|
||||
self._next_ready_event.clear()
|
||||
if self._next_frames:
|
||||
return True
|
||||
waiter = asyncio.create_task(self._next_ready_event.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{task, waiter},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
finally:
|
||||
waiter.cancel()
|
||||
try:
|
||||
await waiter
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return True
|
||||
|
||||
async def _show_next_frame(self) -> bool:
|
||||
"""Display the next buffered frame, rendering on demand only if empty."""
|
||||
if self._current_frame is None:
|
||||
return await self._rebuild_current_frame()
|
||||
|
||||
await self._await_preloaded_frame()
|
||||
if self._next_frames:
|
||||
frame = self._next_frames.popleft()
|
||||
else:
|
||||
items = self._effective_items()
|
||||
frame = await self._render_available_frame(
|
||||
self._current_frame.cursor,
|
||||
items,
|
||||
advance=True,
|
||||
)
|
||||
|
||||
if self._buffer_depth > 0:
|
||||
self._previous_frames.append(self._current_frame)
|
||||
self._trim_timeline()
|
||||
self._apply_frame(frame)
|
||||
self._schedule_preload()
|
||||
return True
|
||||
|
||||
async def _show_previous_frame(self) -> bool:
|
||||
"""Restore the most recent retained frame without I/O or composition."""
|
||||
if not self._previous_frames:
|
||||
return False
|
||||
|
||||
frame = self._previous_frames.pop()
|
||||
if self._current_frame is not None and self._buffer_depth > 0:
|
||||
self._next_frames.appendleft(self._current_frame)
|
||||
self._trim_timeline()
|
||||
self._apply_frame(frame)
|
||||
self._schedule_preload()
|
||||
return True
|
||||
|
||||
async def _rebuild_current_frame(self) -> bool:
|
||||
"""Render the current cursor after startup or playlist/settings changes."""
|
||||
generation = self._timeline_generation
|
||||
self._timeline_dirty = False
|
||||
items = self._effective_items()
|
||||
if not items:
|
||||
return False
|
||||
|
||||
cursor = (
|
||||
self._current_frame.cursor
|
||||
if self._current_frame is not None
|
||||
else self._capture_cursor()
|
||||
)
|
||||
frame = await self._render_available_frame(cursor, items, advance=False)
|
||||
if generation != self._timeline_generation:
|
||||
return False
|
||||
self._previous_frames.clear()
|
||||
self._next_frames.clear()
|
||||
self._apply_frame(frame)
|
||||
self._schedule_preload()
|
||||
return True
|
||||
|
||||
async def _wait_or_interrupt(self, timeout: float) -> bool:
|
||||
"""Wait for configuration/navigation or for the slide timer to expire."""
|
||||
if self._timeline_dirty:
|
||||
return True
|
||||
self._interrupt_event.clear()
|
||||
# Close the clear/wait race: callbacks cannot run between these two
|
||||
# synchronous statements without setting the event again.
|
||||
if self._timeline_dirty:
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
|
||||
if self.store.paused:
|
||||
await self._interrupt_event.wait()
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
self._interrupt_event.wait(),
|
||||
timeout=timeout,
|
||||
)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def _render_loop(self, initial_delay: float = 0.0) -> None:
|
||||
"""Background task: render slides into _framebuffer, advance on timer or interrupt."""
|
||||
"""Display buffered frames on command/timer and refill them in the background."""
|
||||
if initial_delay > 0:
|
||||
try:
|
||||
await asyncio.sleep(initial_delay)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
should_advance = False # Don't advance on the very first render
|
||||
while True:
|
||||
# Clear the wake signal before rendering. Anything that happens
|
||||
# from here on (a "next slide" press, a coordinator/store change)
|
||||
# re-sets it and is picked up after this frame commits, so no
|
||||
# wake is lost while we're mid-render.
|
||||
self._interrupt_event.clear()
|
||||
|
||||
while self._current_frame is None:
|
||||
try:
|
||||
await self._render_cycle(advance=should_advance)
|
||||
async with self._navigation_lock:
|
||||
if self._current_frame is None:
|
||||
await self._rebuild_current_frame()
|
||||
if self._current_frame is not None:
|
||||
self._consecutive_failures = 0
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
self._consecutive_failures += 1
|
||||
backoff = min(2 ** self._consecutive_failures, 60)
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: render cycle failed (attempt %d), retrying in %ds: %s",
|
||||
self._consecutive_failures, backoff, err,
|
||||
"Album Slideshow: initial render failed (attempt %d), retrying in %ds: %s",
|
||||
self._consecutive_failures,
|
||||
backoff,
|
||||
err,
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
should_advance = True # Skip the broken image on retry
|
||||
if not self._effective_items():
|
||||
self._interrupt_event.clear()
|
||||
if not self._effective_items():
|
||||
await self._interrupt_event.wait()
|
||||
|
||||
while True:
|
||||
try:
|
||||
if self._timeline_dirty:
|
||||
async with self._navigation_lock:
|
||||
if self._timeline_dirty:
|
||||
await self._rebuild_current_frame()
|
||||
continue
|
||||
|
||||
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
|
||||
if self._force_next:
|
||||
# Explicit "next slide" request: always advance.
|
||||
should_advance = True
|
||||
self._force_next = False
|
||||
elif interrupted:
|
||||
# A coordinator/store change woke us: re-render the current
|
||||
# frame (new data or settings) without skipping ahead.
|
||||
should_advance = False
|
||||
else:
|
||||
# Paused slideshows hold the current frame until the user
|
||||
# un-pauses or hits "next slide" explicitly.
|
||||
should_advance = not bool(self.store.paused)
|
||||
|
||||
async def _render_cycle(self, advance: bool) -> None:
|
||||
"""Render one frame.
|
||||
|
||||
The slideshow is just "advance index, compose, encode, broadcast".
|
||||
Visible transitions are handled by the Lovelace card client-side,
|
||||
so this path stays minimal: at most one PIL decode + encode per
|
||||
slide change.
|
||||
|
||||
Compose work is serialised across all albums via a domain-wide
|
||||
semaphore so 4 cameras don't all decode + encode at once.
|
||||
"""
|
||||
items: list[MediaItem] = self._effective_items()
|
||||
if not items:
|
||||
return
|
||||
|
||||
count = len(items)
|
||||
if advance:
|
||||
self._do_advance(count, items)
|
||||
|
||||
async with self._compose_semaphore:
|
||||
composed, meta = await self._compose_for_index(items)
|
||||
if composed is None:
|
||||
return
|
||||
try:
|
||||
await self._commit_composed(composed, meta)
|
||||
finally:
|
||||
ip.safe_close(composed)
|
||||
|
||||
async def _commit_composed(self, composed: Image.Image, meta: dict) -> None:
|
||||
"""Encode the composed slide into the framebuffer and broadcast.
|
||||
|
||||
Encodes off the loop so the JPEG encode (30-80 ms at 1080p, more
|
||||
at 4K) doesn't block HA.
|
||||
"""
|
||||
encoded = await self.hass.async_add_executor_job(
|
||||
ip.encode_image, composed
|
||||
interrupted = await self._wait_or_interrupt(
|
||||
float(int(self.store.slide_interval))
|
||||
)
|
||||
if not interrupted and not self.store.paused:
|
||||
async with self._navigation_lock:
|
||||
if not self._timeline_dirty:
|
||||
await self._show_next_frame()
|
||||
self._consecutive_failures = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err:
|
||||
self._consecutive_failures += 1
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: buffered navigation/render failed (attempt %d): %s",
|
||||
self._consecutive_failures,
|
||||
err,
|
||||
)
|
||||
|
||||
self._framebuffer = encoded
|
||||
self.store.last_frame = encoded
|
||||
self._frame_id += 1
|
||||
if meta:
|
||||
self._last_is_portrait = meta.get("is_portrait")
|
||||
else:
|
||||
self._last_is_portrait = None
|
||||
self._last_captured_at_pair = meta.get("captured_at_pair") if meta else None
|
||||
self._last_pair_frames = meta.get("pair_frames") if meta else None
|
||||
self._last_pair_orientation = meta.get("pair_orientation") if meta else None
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def _compose_semaphore(self) -> asyncio.Semaphore:
|
||||
@@ -849,6 +1226,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
return None
|
||||
|
||||
async def _http_get(self, url: str) -> bytes | None:
|
||||
"""Fetch one remote image with validation and a hard timeout."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
@@ -52,8 +53,34 @@ from .const import (
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
CONF_ICLOUD_BACKEND,
|
||||
ICLOUD_IMAGE_FULL,
|
||||
ICLOUD_IMAGE_PREVIEW,
|
||||
CONF_SYNOLOGY_URL,
|
||||
CONF_SYNOLOGY_USERNAME,
|
||||
CONF_SYNOLOGY_PASSWORD,
|
||||
CONF_SYNOLOGY_DEVICE_ID,
|
||||
CONF_SYNOLOGY_SPACE,
|
||||
CONF_SYNOLOGY_ALBUM_ID,
|
||||
CONF_SYNOLOGY_IMAGE_SIZE,
|
||||
CONF_SYNOLOGY_PASSPHRASE,
|
||||
CONF_SYNOLOGY_FAVORITE,
|
||||
CONF_SYNOLOGY_SELECTION,
|
||||
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
||||
SYNOLOGY_SPACE_PERSONAL,
|
||||
SYNOLOGY_SPACE_SHARED,
|
||||
SYNOLOGY_IMAGE_SMALL,
|
||||
SYNOLOGY_IMAGE_MEDIUM,
|
||||
SYNOLOGY_IMAGE_LARGE,
|
||||
CONF_NEXTCLOUD_URL,
|
||||
CONF_NEXTCLOUD_USERNAME,
|
||||
CONF_NEXTCLOUD_PASSWORD,
|
||||
CONF_NEXTCLOUD_FOLDER,
|
||||
CONF_NEXTCLOUD_RECURSIVE,
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE,
|
||||
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
|
||||
NEXTCLOUD_IMAGE_PREVIEW,
|
||||
NEXTCLOUD_IMAGE_ORIGINAL,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
@@ -61,6 +88,8 @@ from .const import (
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -88,6 +117,15 @@ def _normalize_local_path(hass, path: str) -> str:
|
||||
|
||||
ALBUM_URL_RE = re.compile(r"^https?://photos\.app\.goo\.gl/[^/]+/?$")
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _describe_error(err: BaseException) -> str:
|
||||
"""Render an exception for the log, including an HTTP status when known."""
|
||||
status = getattr(err, "status", None)
|
||||
detail = str(err) or type(err).__name__
|
||||
return f"HTTP {status}: {detail}" if status else f"{type(err).__name__}: {detail}"
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
@@ -108,6 +146,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._pp_password: str | None = None
|
||||
self._pp_albums: dict[str, str] = {}
|
||||
self._pp_people: dict[str, str] = {}
|
||||
# Synology flow state carried between steps.
|
||||
self._syn_url: str | None = None
|
||||
self._syn_username: str | None = None
|
||||
self._syn_password: str | None = None
|
||||
self._syn_device_id: str | None = None
|
||||
self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
|
||||
self._syn_albums: dict[str, str] = {}
|
||||
# option key -> {"album_id": id|None, "passphrase": str|None}
|
||||
self._syn_album_meta: dict[str, dict[str, Any]] = {}
|
||||
# id(str) -> name maps for the composite category multi-selects.
|
||||
self._syn_people: dict[str, str] = {}
|
||||
self._syn_places: dict[str, str] = {}
|
||||
self._syn_tags: dict[str, str] = {}
|
||||
self._syn_subjects: dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -126,7 +178,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
``__init__`` raises (the symptom is a 500 when the user clicks
|
||||
Configure).
|
||||
"""
|
||||
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
|
||||
if config_entry.data.get(CONF_PROVIDER) in (
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
):
|
||||
return LocalFolderOptionsFlow()
|
||||
return _NoOptionsFlow()
|
||||
|
||||
@@ -143,6 +198,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_photoprism()
|
||||
if self._provider == PROVIDER_ICLOUD:
|
||||
return await self.async_step_icloud()
|
||||
if self._provider == PROVIDER_SYNOLOGY:
|
||||
return await self.async_step_synology()
|
||||
if self._provider == PROVIDER_NEXTCLOUD:
|
||||
return await self.async_step_nextcloud()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -153,6 +212,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
|
||||
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
||||
PROVIDER_ICLOUD: "iCloud Shared Album",
|
||||
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
|
||||
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder, full metadata)",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -169,7 +230,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if not ALBUM_URL_RE.match(url):
|
||||
errors[CONF_ALBUM_URL] = "invalid_album_url"
|
||||
else:
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}:{name}")
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
@@ -199,7 +260,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if not path:
|
||||
errors[CONF_LOCAL_PATH] = "invalid_path"
|
||||
else:
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
|
||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}:{name}")
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
@@ -233,7 +294,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}"
|
||||
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}:{name}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
@@ -267,13 +328,43 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
from . import immich as immich_api
|
||||
|
||||
client = immich_api.ImmichClient(self.hass, url, key)
|
||||
albums: list[dict[str, Any]] = []
|
||||
people: list[dict[str, Any]] = []
|
||||
try:
|
||||
await client.async_validate()
|
||||
albums = await client.async_list_albums()
|
||||
people = await client.async_list_people()
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/key
|
||||
errors["base"] = "immich_cannot_connect"
|
||||
except Exception as err: # noqa: BLE001 - any failure means bad URL/key
|
||||
_LOGGER.warning(
|
||||
"Immich validation failed for %s: %s",
|
||||
client.base_url,
|
||||
_describe_error(err),
|
||||
)
|
||||
errors["base"] = (
|
||||
"immich_invalid_auth"
|
||||
if getattr(err, "status", None) in (401, 403)
|
||||
else "immich_cannot_connect"
|
||||
)
|
||||
else:
|
||||
# A key with limited permissions can read the server but not
|
||||
# albums or people; keep going with whatever it can see.
|
||||
for label, call in (
|
||||
("albums", client.async_list_albums),
|
||||
("people", client.async_list_people),
|
||||
):
|
||||
try:
|
||||
result = await call()
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning(
|
||||
"Immich %s listing failed for %s: %s",
|
||||
label,
|
||||
client.base_url,
|
||||
_describe_error(err),
|
||||
)
|
||||
else:
|
||||
if label == "albums":
|
||||
albums = result
|
||||
else:
|
||||
people = result
|
||||
|
||||
self._immich_url = client.base_url
|
||||
self._immich_key = key
|
||||
# id -> name maps for the two multi-select pickers.
|
||||
@@ -345,7 +436,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
sel_id = json.dumps(selection, sort_keys=True)
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
|
||||
f"composite:{sel_id}:{raw_filter}"
|
||||
f"composite:{sel_id}:{raw_filter}:{name}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -524,7 +615,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
sel_id = json.dumps(selection, sort_keys=True)
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
|
||||
f"composite:{sel_id}:{raw_filter}"
|
||||
f"composite:{sel_id}:{raw_filter}:{name}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -609,9 +700,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
from . import icloud as icloud_api
|
||||
|
||||
token = icloud_api.parse_share_link(raw_url)
|
||||
if not token:
|
||||
parsed = icloud_api.parse_share(raw_url)
|
||||
if not parsed:
|
||||
errors[CONF_ICLOUD_URL] = "invalid_icloud_url"
|
||||
else:
|
||||
token, backend = parsed
|
||||
if backend == icloud_api.BACKEND_CLOUDKIT:
|
||||
client = icloud_api.IcloudCloudKitClient(self.hass, token)
|
||||
else:
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
try:
|
||||
@@ -620,7 +715,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "icloud_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}"
|
||||
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}:{name}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
@@ -628,6 +723,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_ICLOUD,
|
||||
CONF_ICLOUD_TOKEN: token,
|
||||
CONF_ICLOUD_BACKEND: backend,
|
||||
CONF_ICLOUD_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
@@ -651,6 +747,305 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="icloud", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_synology(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect the Synology URL + credentials (and optional 2FA code)."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_SYNOLOGY_URL].strip()
|
||||
username = user_input[CONF_SYNOLOGY_USERNAME].strip()
|
||||
password = user_input.get(CONF_SYNOLOGY_PASSWORD) or ""
|
||||
space = user_input.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
|
||||
otp = (user_input.get("otp_code") or "").strip()
|
||||
|
||||
from . import synology as syn_api
|
||||
|
||||
client = syn_api.SynologyClient(
|
||||
self.hass,
|
||||
url,
|
||||
username=username,
|
||||
password=password,
|
||||
space=space,
|
||||
)
|
||||
try:
|
||||
await client.async_login(otp_code=otp or None)
|
||||
except syn_api.SynologyOtpRequired:
|
||||
errors["otp_code"] = "synology_otp_required"
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds
|
||||
errors["base"] = "synology_cannot_connect"
|
||||
|
||||
if not errors:
|
||||
# Login worked, so the URL and credentials are fine. Albums and
|
||||
# category browsing live only in the Personal space (there is no
|
||||
# Shared Space album/category API). For the Shared Space we just
|
||||
# validate access up front; any failure here means the Shared
|
||||
# Space is not enabled or this account cannot reach it, not a
|
||||
# credentials problem.
|
||||
try:
|
||||
if space == SYNOLOGY_SPACE_SHARED:
|
||||
albums = people = places = tags = subjects = []
|
||||
await client.async_collect_assets(None)
|
||||
else:
|
||||
albums = await client.async_list_albums()
|
||||
people = await client.async_list_people()
|
||||
places = await client.async_list_places()
|
||||
tags = await client.async_list_tags()
|
||||
subjects = await client.async_list_subjects()
|
||||
except Exception: # noqa: BLE001
|
||||
if space == SYNOLOGY_SPACE_SHARED:
|
||||
errors["base"] = "synology_shared_unavailable"
|
||||
else:
|
||||
errors["base"] = "synology_cannot_connect"
|
||||
|
||||
if not errors:
|
||||
self._syn_url = client.base_url
|
||||
self._syn_username = username
|
||||
self._syn_password = password
|
||||
self._syn_space = space
|
||||
# A trusted-device token is captured only on the OTP login;
|
||||
# store it so future logins skip the 2FA prompt.
|
||||
self._syn_device_id = client.captured_device_id
|
||||
# Key each album by a synthetic value so an own album and a
|
||||
# shared-with-me album that happen to share a numeric id don't
|
||||
# collide. Track album_id + passphrase per option.
|
||||
self._syn_albums = {}
|
||||
self._syn_album_meta = {}
|
||||
for a in albums:
|
||||
if a.get("id") is None:
|
||||
continue
|
||||
shared = bool(a.get("shared"))
|
||||
key = f"{'shared' if shared else 'own'}:{a['id']}"
|
||||
label = a.get("name") or str(a["id"])
|
||||
self._syn_albums[key] = f"{label} (shared)" if shared else label
|
||||
self._syn_album_meta[key] = {
|
||||
"album_id": None if shared else a["id"],
|
||||
"passphrase": a.get("passphrase") if shared else None,
|
||||
}
|
||||
self._syn_people = {
|
||||
str(p["id"]): p["name"] for p in people if p.get("id") is not None
|
||||
}
|
||||
self._syn_places = {
|
||||
str(p["id"]): p["name"] for p in places if p.get("id") is not None
|
||||
}
|
||||
self._syn_tags = {
|
||||
str(t["id"]): t["name"] for t in tags if t.get("id") is not None
|
||||
}
|
||||
self._syn_subjects = {
|
||||
str(s["id"]): s["name"] for s in subjects if s.get("id") is not None
|
||||
}
|
||||
await client.async_logout()
|
||||
return await self.async_step_synology_select()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SYNOLOGY_URL): str,
|
||||
vol.Required(CONF_SYNOLOGY_USERNAME): str,
|
||||
vol.Required(CONF_SYNOLOGY_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_SYNOLOGY_SPACE, default=SYNOLOGY_SPACE_PERSONAL
|
||||
): vol.In(
|
||||
{
|
||||
SYNOLOGY_SPACE_PERSONAL: "Personal (My Photos)",
|
||||
SYNOLOGY_SPACE_SHARED: "Shared Space",
|
||||
}
|
||||
),
|
||||
vol.Optional("otp_code"): str,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="synology", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_synology_select(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Build a composite Synology selection and finish the entry.
|
||||
|
||||
Like the Immich/PhotoPrism providers: tick any mix of favorites,
|
||||
albums, people, places, tags and subjects. Synology has no OR across
|
||||
categories, so each member is queried on its own and merged. An empty
|
||||
selection means the whole space.
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
size = user_input.get(
|
||||
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||
)
|
||||
favorites = bool(user_input.get("favorites"))
|
||||
|
||||
album_ids: list[Any] = []
|
||||
passphrases: list[str] = []
|
||||
for key in user_input.get("albums", []) or []:
|
||||
meta = self._syn_album_meta.get(key)
|
||||
if not meta:
|
||||
continue
|
||||
if meta.get("passphrase"):
|
||||
passphrases.append(meta["passphrase"])
|
||||
elif meta.get("album_id") is not None:
|
||||
album_ids.append(meta["album_id"])
|
||||
|
||||
def _ids(field: str, valid: dict[str, str]) -> list[int]:
|
||||
out: list[int] = []
|
||||
for v in user_input.get(field, []) or []:
|
||||
if v in valid:
|
||||
try:
|
||||
out.append(int(v))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return out
|
||||
|
||||
selection = {
|
||||
"favorites": favorites,
|
||||
"album_ids": album_ids,
|
||||
"passphrases": passphrases,
|
||||
"person_ids": _ids("people", self._syn_people),
|
||||
"geocoding_ids": _ids("places", self._syn_places),
|
||||
"tag_ids": _ids("tags", self._syn_tags),
|
||||
"concept_ids": _ids("subjects", self._syn_subjects),
|
||||
}
|
||||
sel_id = json.dumps(selection, sort_keys=True)
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
||||
f"{self._syn_space}:{sel_id}:{name}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
data = {
|
||||
CONF_PROVIDER: PROVIDER_SYNOLOGY,
|
||||
CONF_SYNOLOGY_URL: self._syn_url,
|
||||
CONF_SYNOLOGY_USERNAME: self._syn_username,
|
||||
CONF_SYNOLOGY_PASSWORD: self._syn_password,
|
||||
CONF_SYNOLOGY_SPACE: self._syn_space,
|
||||
CONF_SYNOLOGY_SELECTION: sel_id,
|
||||
CONF_SYNOLOGY_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
}
|
||||
if self._syn_device_id:
|
||||
data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id
|
||||
return self.async_create_entry(title=name, data=data)
|
||||
|
||||
def _multi(options: dict[str, str]):
|
||||
return selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
selector.SelectOptionDict(value=v, label=l)
|
||||
for v, l in options.items()
|
||||
],
|
||||
multiple=True,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
custom_value=False,
|
||||
)
|
||||
)
|
||||
|
||||
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
|
||||
# Favorites, albums and subjects are Personal-space concepts.
|
||||
if self._syn_space == SYNOLOGY_SPACE_PERSONAL:
|
||||
fields[vol.Optional("favorites", default=False)] = (
|
||||
selector.BooleanSelector()
|
||||
)
|
||||
if self._syn_albums:
|
||||
fields[vol.Optional("albums")] = _multi(self._syn_albums)
|
||||
if self._syn_people:
|
||||
fields[vol.Optional("people")] = _multi(self._syn_people)
|
||||
if self._syn_places:
|
||||
fields[vol.Optional("places")] = _multi(self._syn_places)
|
||||
if self._syn_tags:
|
||||
fields[vol.Optional("tags")] = _multi(self._syn_tags)
|
||||
if self._syn_subjects:
|
||||
fields[vol.Optional("subjects")] = _multi(self._syn_subjects)
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||
)
|
||||
] = vol.In(
|
||||
{
|
||||
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
|
||||
SYNOLOGY_IMAGE_MEDIUM: "Medium",
|
||||
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="synology_select", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_nextcloud(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect and validate a Nextcloud WebDAV folder + app password."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
name = user_input[CONF_ALBUM_NAME].strip()
|
||||
url = user_input[CONF_NEXTCLOUD_URL].strip()
|
||||
username = user_input[CONF_NEXTCLOUD_USERNAME].strip()
|
||||
password = user_input.get(CONF_NEXTCLOUD_PASSWORD) or ""
|
||||
folder = (user_input.get(CONF_NEXTCLOUD_FOLDER) or "").strip()
|
||||
recursive = bool(user_input.get(CONF_NEXTCLOUD_RECURSIVE, False))
|
||||
size = user_input.get(
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
)
|
||||
|
||||
from . import nextcloud as nc_api
|
||||
|
||||
client = nc_api.NextcloudClient(
|
||||
self.hass, url, username, password, folder
|
||||
)
|
||||
try:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds/folder
|
||||
errors["base"] = "nextcloud_cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
|
||||
f"{username}:{client.folder}:{name}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={
|
||||
CONF_PROVIDER: PROVIDER_NEXTCLOUD,
|
||||
CONF_NEXTCLOUD_URL: client.base_url,
|
||||
CONF_NEXTCLOUD_USERNAME: username,
|
||||
CONF_NEXTCLOUD_PASSWORD: password,
|
||||
CONF_NEXTCLOUD_FOLDER: client.folder,
|
||||
CONF_NEXTCLOUD_RECURSIVE: recursive,
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE: size,
|
||||
CONF_ALBUM_NAME: name,
|
||||
},
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_NAME): str,
|
||||
vol.Required(CONF_NEXTCLOUD_URL): str,
|
||||
vol.Required(CONF_NEXTCLOUD_USERNAME): str,
|
||||
vol.Required(CONF_NEXTCLOUD_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Optional(CONF_NEXTCLOUD_FOLDER, default=""): str,
|
||||
vol.Optional(CONF_NEXTCLOUD_RECURSIVE, default=False): (
|
||||
selector.BooleanSelector()
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, default=DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
): vol.In(
|
||||
{
|
||||
NEXTCLOUD_IMAGE_PREVIEW: "Preview (smoothest slideshow)",
|
||||
NEXTCLOUD_IMAGE_ORIGINAL: "Original (full quality, slower)",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="nextcloud", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Options for local-folder entries.
|
||||
|
||||
@@ -57,6 +57,71 @@ PROVIDER_MEDIA_SOURCE = "media_source"
|
||||
PROVIDER_IMMICH = "immich"
|
||||
PROVIDER_PHOTOPRISM = "photoprism"
|
||||
PROVIDER_ICLOUD = "icloud"
|
||||
PROVIDER_SYNOLOGY = "synology"
|
||||
PROVIDER_NEXTCLOUD = "nextcloud"
|
||||
|
||||
# Nextcloud (authenticated WebDAV folder) provider. Points at any folder in a
|
||||
# user's files and lists it over WebDAV. Auth is HTTP Basic with a username +
|
||||
# app password (Settings > Security > Devices & sessions); the app password is
|
||||
# stored so the coordinator can re-list on each refresh and is sent server-side
|
||||
# only, never reaching the browser.
|
||||
CONF_NEXTCLOUD_URL = "nextcloud_url"
|
||||
CONF_NEXTCLOUD_USERNAME = "nextcloud_username"
|
||||
CONF_NEXTCLOUD_PASSWORD = "nextcloud_password"
|
||||
CONF_NEXTCLOUD_FOLDER = "nextcloud_folder"
|
||||
CONF_NEXTCLOUD_RECURSIVE = "nextcloud_recursive"
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE = "nextcloud_image_size"
|
||||
|
||||
# ``preview`` uses the core/preview thumbnail endpoint (smoother, smaller);
|
||||
# ``original`` fetches the real file straight off the WebDAV collection.
|
||||
NEXTCLOUD_IMAGE_PREVIEW = "preview"
|
||||
NEXTCLOUD_IMAGE_ORIGINAL = "original"
|
||||
NEXTCLOUD_IMAGE_SIZE_OPTIONS = [NEXTCLOUD_IMAGE_PREVIEW, NEXTCLOUD_IMAGE_ORIGINAL]
|
||||
DEFAULT_NEXTCLOUD_IMAGE_SIZE = NEXTCLOUD_IMAGE_PREVIEW
|
||||
# Long edge (px) requested from the core/preview endpoint for preview quality.
|
||||
NEXTCLOUD_PREVIEW_PX = 1920
|
||||
|
||||
# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
|
||||
# entry.cgi web API. The account password is stored so the coordinator can
|
||||
# re-authenticate when the session id expires; accounts with 2FA are handled by
|
||||
# capturing a trusted-device token during setup (see synology.py).
|
||||
CONF_SYNOLOGY_URL = "synology_url"
|
||||
CONF_SYNOLOGY_USERNAME = "synology_username"
|
||||
CONF_SYNOLOGY_PASSWORD = "synology_password"
|
||||
CONF_SYNOLOGY_DEVICE_ID = "synology_device_id"
|
||||
CONF_SYNOLOGY_SPACE = "synology_space"
|
||||
CONF_SYNOLOGY_ALBUM_ID = "synology_album_id"
|
||||
CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size"
|
||||
# Passphrase for an album that was shared with the configured account. Present
|
||||
# only when the chosen source is a shared-with-me album; such albums are
|
||||
# reachable by passphrase rather than by album id.
|
||||
CONF_SYNOLOGY_PASSPHRASE = "synology_passphrase"
|
||||
# When True, the source is the account's Favorites (favorited photos) rather
|
||||
# than the whole space or a specific album.
|
||||
CONF_SYNOLOGY_FAVORITE = "synology_favorite"
|
||||
# Composite selection: a client-side union of any mix of albums, people,
|
||||
# places, tags, subjects and favorites. Synology has no OR across categories,
|
||||
# so each selected member is queried on its own and the results are merged
|
||||
# (see the Immich/PhotoPrism composite). Stored as a JSON object:
|
||||
# ``{"favorites": bool, "album_ids": [...], "passphrases": [...],
|
||||
# "person_ids": [...], "geocoding_ids": [...], "tag_ids": [...],
|
||||
# "concept_ids": [...]}``; an empty composite means the whole space.
|
||||
CONF_SYNOLOGY_SELECTION = "synology_selection"
|
||||
|
||||
# Personal ("My Photos") vs shared ("Shared Space") library.
|
||||
SYNOLOGY_SPACE_PERSONAL = "personal"
|
||||
SYNOLOGY_SPACE_SHARED = "shared"
|
||||
|
||||
# Native Synology thumbnail sizes. ``xl`` is the largest (best for a slideshow).
|
||||
SYNOLOGY_IMAGE_SMALL = "sm"
|
||||
SYNOLOGY_IMAGE_MEDIUM = "m"
|
||||
SYNOLOGY_IMAGE_LARGE = "xl"
|
||||
SYNOLOGY_IMAGE_SIZE_OPTIONS = [
|
||||
SYNOLOGY_IMAGE_SMALL,
|
||||
SYNOLOGY_IMAGE_MEDIUM,
|
||||
SYNOLOGY_IMAGE_LARGE,
|
||||
]
|
||||
DEFAULT_SYNOLOGY_IMAGE_SIZE = SYNOLOGY_IMAGE_LARGE
|
||||
|
||||
# iCloud Shared Album provider. The share token in the pasted link is the only
|
||||
# credential; no account or password is involved.
|
||||
@@ -64,6 +129,17 @@ CONF_ICLOUD_URL = "icloud_url"
|
||||
CONF_ICLOUD_TOKEN = "icloud_token"
|
||||
CONF_ICLOUD_IMAGE_SIZE = "icloud_image_size"
|
||||
|
||||
# Which iCloud backend serves the album. Older share links
|
||||
# (``www.icloud.com/sharedalbum/#TOKEN``) use the legacy "shared streams" web
|
||||
# API; iOS 26/macOS 26 and newer links
|
||||
# (``photos.icloud.com/shared/album/TOKEN``) use CloudKit Web Services. Both are
|
||||
# public and need no account. Entries created before this option existed default
|
||||
# to the legacy backend.
|
||||
CONF_ICLOUD_BACKEND = "icloud_backend"
|
||||
ICLOUD_BACKEND_SHAREDSTREAMS = "sharedstreams"
|
||||
ICLOUD_BACKEND_CLOUDKIT = "cloudkit"
|
||||
DEFAULT_ICLOUD_BACKEND = ICLOUD_BACKEND_SHAREDSTREAMS
|
||||
|
||||
# ``full`` picks the largest derivative Apple generated (best for a slideshow,
|
||||
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
|
||||
ICLOUD_IMAGE_FULL = "full"
|
||||
@@ -178,6 +254,10 @@ DEFAULT_RECURSIVE = True
|
||||
# path. Users with one album and lots of RAM can bump this via the
|
||||
# Image cache size number entity.
|
||||
DEFAULT_IMAGE_CACHE_MB = 75
|
||||
# Number of fully rendered slides retained on each side of the current frame.
|
||||
# Previous/Next can swap these JPEGs immediately without downloading,
|
||||
# composing, or encoding during the button press.
|
||||
DEFAULT_NAVIGATION_BUFFER_SIZE = 2
|
||||
|
||||
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
|
||||
DEFAULT_MAX_RESOLUTION = "1080p"
|
||||
@@ -193,5 +273,6 @@ MAX_RESOLUTION_SHORT_EDGE: dict[str, int | None] = {
|
||||
PUBLICALBUM_ENDPOINT = "https://www.publicalbum.org/api/v2/webapp/embed-player/jsonrpc"
|
||||
|
||||
SERVICE_NEXT_SLIDE = "next_slide"
|
||||
SERVICE_PREVIOUS_SLIDE = "previous_slide"
|
||||
SERVICE_REFRESH_ALBUM = "refresh_album"
|
||||
ATTR_ENTRY_ID = "entry_id"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user