Compare commits
1834
Commits
0ed1687503
...
main
+1
-1
@@ -1 +1 @@
|
||||
2026.7.2
|
||||
2026.8.1
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 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.1", "start_ts": 1786715408.727179}
|
||||
@@ -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,205 @@
|
||||
{
|
||||
"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": "input_boolean.blink_backyard_camera_motion"
|
||||
},
|
||||
{
|
||||
"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.night_light_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,663 @@
|
||||
{
|
||||
"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": "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.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.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,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",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace.printer_stats",
|
||||
"data": {
|
||||
"config": {
|
||||
"views": [
|
||||
{
|
||||
"title": "Print",
|
||||
"path": "print",
|
||||
"type": "sections",
|
||||
"max_columns": 4,
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Printer Status",
|
||||
"icon": "mdi:printer-3d-nozzle"
|
||||
},
|
||||
{
|
||||
"type": "custom:webrtc-camera",
|
||||
"url": "octoprint",
|
||||
"mode": "mse",
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "binary_sensor.octoprint_printing",
|
||||
"name": "Printing",
|
||||
"icon": "mdi:printer-3d",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "binary_sensor.octoprint_connected",
|
||||
"name": "Connected",
|
||||
"icon": "mdi:lan-connect",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_print_status",
|
||||
"name": "Status",
|
||||
"icon": "mdi:state-machine",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_last_event",
|
||||
"name": "Last Event",
|
||||
"icon": "mdi:history",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "binary_sensor.octoprint_paused",
|
||||
"name": "Paused",
|
||||
"icon": "mdi:pause",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "**File:** {{ states('sensor.octoprint_print_file') }}\n**Size:** {{ (states('sensor.octoprint_print_file_size') | int(0) / 1048576) | round(2) }} MB\n**User:** {{ states('sensor.octoprint_print_user') }}",
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_print_file",
|
||||
"name": "File",
|
||||
"icon": "mdi:file-code",
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Print Progress",
|
||||
"icon": "mdi:progress-clock"
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.octoprint_print_progress_live",
|
||||
"name": "Progress",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"unit": "%",
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 40,
|
||||
"red": 90
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6,
|
||||
"rows": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:mushroom-template-card",
|
||||
"primary": "{% set s = states('sensor.octoprint_elapsed') | float(0) %}{% set h = (s / 3600) | int %}{% set m = ((s % 3600) / 60) | int %}Elapsed: {% if h %}{{ h }} hr {{ m }} min{% else %}{{ m }} min{% endif %}",
|
||||
"name": "Elapsed",
|
||||
"icon": "mdi:clock-start",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:mushroom-template-card",
|
||||
"primary": "{% set s = states('sensor.octoprint_time_left') | float(-1) %}{% if s <= 0 %}Remaining: No estimate{% else %}{% set h = (s / 3600) | int %}{% set m = ((s % 3600) / 60) | int %}Remaining: {% if h %}{{ h }} hr {{ m }} min{% else %}{{ m }} min{% endif %}{% endif %}",
|
||||
"name": "Remaining",
|
||||
"icon": "mdi:clock-end",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "{% set finish = states('sensor.octoprint_approximate_completion_time') | as_datetime %}{% if finish %}<ha-icon icon='mdi:clock-outline'></ha-icon> **Est. finish:** {{ (as_local(finish)).strftime('%I:%M %p').lstrip('0') }}{% else %}<ha-icon icon='mdi:clock-outline'></ha-icon> **Est. finish:** —{% endif %}",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_current_z",
|
||||
"name": "Current Z",
|
||||
"icon": "mdi:axis-z-arrow",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "light.3d_printer_lights",
|
||||
"name": "Printer Light",
|
||||
"icon": "mdi:lightbulb",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "sensor",
|
||||
"entity": "sensor.octoprint_print_progress_live",
|
||||
"name": "Progress History",
|
||||
"graph": "line",
|
||||
"hours_to_show": 24,
|
||||
"detail": 1,
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Temperatures",
|
||||
"icon": "mdi:thermometer"
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.octoprint_tool_0_temperature",
|
||||
"name": "Nozzle",
|
||||
"min": 0,
|
||||
"max": 260,
|
||||
"unit": "°C",
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 180,
|
||||
"red": 240
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.octoprint_bed_temperature",
|
||||
"name": "Bed",
|
||||
"min": 0,
|
||||
"max": 120,
|
||||
"unit": "°C",
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 60,
|
||||
"red": 100
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "gauge",
|
||||
"entity": "sensor.octoprint_soc_temperature",
|
||||
"name": "SoC",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"unit": "°C",
|
||||
"severity": {
|
||||
"green": 0,
|
||||
"yellow": 70,
|
||||
"red": 90
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_tool_0_target",
|
||||
"name": "Nozzle Target",
|
||||
"icon": "mdi:printer-3d-nozzle",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_bed_target",
|
||||
"name": "Bed Target",
|
||||
"icon": "mdi:radiator",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Controls",
|
||||
"icon": "mdi:gesture-tap-button"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "switch.octoprint_pause_print",
|
||||
"name": "Pause",
|
||||
"icon": "mdi:pause",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "switch.octoprint_connect_to_printer",
|
||||
"name": "Connect",
|
||||
"icon": "mdi:lan-connect",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "switch.octoprint_camera_snapshot",
|
||||
"name": "Snapshot",
|
||||
"icon": "mdi:camera",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "button.octoprint_cancel_print",
|
||||
"name": "Cancel Print",
|
||||
"icon": "mdi:cancel",
|
||||
"tap_action": {
|
||||
"action": "perform-action",
|
||||
"perform_action": "button.press",
|
||||
"target": {
|
||||
"entity_id": "button.octoprint_cancel_print"
|
||||
}
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "button.octoprint_emergency_stop",
|
||||
"name": "Emergency Stop",
|
||||
"icon": "mdi:stop-circle",
|
||||
"tap_action": {
|
||||
"action": "perform-action",
|
||||
"perform_action": "button.press",
|
||||
"target": {
|
||||
"entity_id": "button.octoprint_emergency_stop"
|
||||
}
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"name": "GCode Viewer",
|
||||
"icon": "mdi:cube-outline",
|
||||
"tap_action": {
|
||||
"action": "url",
|
||||
"url_path": "https://ender5pro.duckdns.org/#gcode"
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"name": "Pretty GCode Viewer",
|
||||
"icon": "mdi:cube-outline",
|
||||
"tap_action": {
|
||||
"action": "url",
|
||||
"url_path": "https://ender5pro.duckdns.org/#tab_plugin_prettygcode"
|
||||
},
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Totals",
|
||||
"icon": "mdi:chart-box-outline"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_printer_print_time_today",
|
||||
"name": "Print Time Today",
|
||||
"icon": "mdi:clock-start",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_printer_print_time_this_week",
|
||||
"name": "Print Time This Week",
|
||||
"icon": "mdi:calendar-week",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
},
|
||||
"entity": "sensor.octoprint_printer_print_time_last_7_days",
|
||||
"name": "Print Time Last 7 Days",
|
||||
"icon": "mdi:history",
|
||||
"vertical": false,
|
||||
"features_position": "bottom"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_printer_prints_today",
|
||||
"name": "Prints Today",
|
||||
"icon": "mdi:counter",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_printer_prints_this_week",
|
||||
"name": "Prints This Week",
|
||||
"icon": "mdi:counter",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_printer_prints_last_7_days",
|
||||
"name": "Prints Last 7 Days",
|
||||
"icon": "mdi:counter",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Filament Usage",
|
||||
"icon": "mdi:chart-donut"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_last_print_filament",
|
||||
"name": "Last Job",
|
||||
"icon": "mdi:history",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_filament_daily",
|
||||
"name": "Filament Today",
|
||||
"icon": "mdi:chart-donut",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_filament_weekly",
|
||||
"name": "Filament This Week",
|
||||
"icon": "mdi:calendar-week",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_filament_monthly",
|
||||
"name": "Filament This Month",
|
||||
"icon": "mdi:calendar-month",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_filament_used_total",
|
||||
"name": "Grand Total",
|
||||
"icon": "mdi:chart-pie",
|
||||
"grid_options": {
|
||||
"columns": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "History",
|
||||
"path": "history",
|
||||
"type": "sections",
|
||||
"max_columns": 2,
|
||||
"sections": [
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Temperature History",
|
||||
"icon": "mdi:chart-line-variant"
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Temperatures (24h)",
|
||||
"hours_to_show": 24,
|
||||
"show_names": true,
|
||||
"expand_legend": true,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.octoprint_tool_0_temperature",
|
||||
"name": "Nozzle",
|
||||
"color": "#ef5350"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_tool_0_target",
|
||||
"name": "Nozzle Target",
|
||||
"color": "#ffab91"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_bed_temperature",
|
||||
"name": "Bed",
|
||||
"color": "#42a5f5"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_bed_target",
|
||||
"name": "Bed Target",
|
||||
"color": "#90caf9"
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Temperatures (7 days)",
|
||||
"hours_to_show": 168,
|
||||
"show_names": true,
|
||||
"expand_legend": true,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.octoprint_tool_0_temperature",
|
||||
"name": "Nozzle",
|
||||
"color": "#ef5350"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_bed_temperature",
|
||||
"name": "Bed",
|
||||
"color": "#42a5f5"
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Print Activity",
|
||||
"icon": "mdi:chart-box-outline"
|
||||
},
|
||||
{
|
||||
"type": "custom:apexcharts-card",
|
||||
"header": {
|
||||
"show": true,
|
||||
"title": "Z Position (24h)",
|
||||
"show_states": true,
|
||||
"colorize_states": true
|
||||
},
|
||||
"graph_span": "24h",
|
||||
"series": [
|
||||
{
|
||||
"entity": "sensor.octoprint_current_z",
|
||||
"name": "Current Z",
|
||||
"color": "#ab47bc",
|
||||
"type": "line",
|
||||
"curve": "straight",
|
||||
"fill_raw": "last"
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
},
|
||||
"apex_config": {
|
||||
"chart": {
|
||||
"height": 300
|
||||
},
|
||||
"stroke": {
|
||||
"width": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Print Progress (24h)",
|
||||
"hours_to_show": 24,
|
||||
"show_names": true,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.octoprint_print_progress",
|
||||
"name": "Progress",
|
||||
"color": "#66bb6a"
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Print Duration (24h)",
|
||||
"hours_to_show": 24,
|
||||
"show_names": true,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "sensor.octoprint_elapsed",
|
||||
"name": "Elapsed",
|
||||
"color": "#26c6da"
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_time_left",
|
||||
"name": "Remaining",
|
||||
"color": "#ffa726"
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Print History",
|
||||
"icon": "mdi:history"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "**Successful:** {{ states('sensor.octoprint_successful_prints_count') }} | **Failed:** {{ states('sensor.octoprint_failed_prints_count') }}"
|
||||
},
|
||||
{
|
||||
"type": "history-graph",
|
||||
"title": "Printing State (7 days)",
|
||||
"hours_to_show": 168,
|
||||
"show_names": true,
|
||||
"entities": [
|
||||
{
|
||||
"entity": "binary_sensor.octoprint_printing",
|
||||
"name": "Printing",
|
||||
"color": "#66bb6a"
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "custom:apexcharts-card",
|
||||
"header": {
|
||||
"show": true,
|
||||
"title": "Print Outcomes (30 days)",
|
||||
"show_states": true,
|
||||
"colorize_states": true
|
||||
},
|
||||
"graph_span": "30d",
|
||||
"apex_config": {
|
||||
"chart": {
|
||||
"type": "bar",
|
||||
"stacked": true,
|
||||
"height": 300
|
||||
},
|
||||
"plotOptions": {
|
||||
"bar": {
|
||||
"columnWidth": "50%"
|
||||
}
|
||||
},
|
||||
"dataLabels": {
|
||||
"enabled": true
|
||||
},
|
||||
"yaxis": {
|
||||
"tickAmount": 4,
|
||||
"labels": {
|
||||
"minWidth": 40
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"show": true
|
||||
}
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"entity": "sensor.octoprint_successful_prints_count",
|
||||
"name": "Successful",
|
||||
"color": "#66bb6a",
|
||||
"type": "column",
|
||||
"group_by": {
|
||||
"func": "diff",
|
||||
"duration": "1d",
|
||||
"start_with_last": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_failed_prints_count",
|
||||
"name": "Failed",
|
||||
"color": "#ef5350",
|
||||
"type": "column",
|
||||
"group_by": {
|
||||
"func": "diff",
|
||||
"duration": "1d",
|
||||
"start_with_last": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
},
|
||||
"span": {
|
||||
"end": "day"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"title": "Recent Prints",
|
||||
"content": "{% for j in (state_attr('sensor.octoprint_recent_prints', 'allPrintJobs') | default([]))[:5] %}\n{% set d = j.get('duration', 0) | int %}\n- **{{ j.fileName }}** — {{ j.printStatusResult | title }}, {{ (d / 3600) | int }}h {{ ((d % 3600) / 60) | int }}m, {{ j.get('filamentModels', {}).get('total', {}).get('usedLength', 0) | round(0) }}mm\n{% endfor %}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "Filament Usage",
|
||||
"icon": "mdi:spool"
|
||||
},
|
||||
{
|
||||
"type": "custom:apexcharts-card",
|
||||
"header": {
|
||||
"show": true,
|
||||
"title": "Filament Usage (30 days)",
|
||||
"show_states": true,
|
||||
"colorize_states": true
|
||||
},
|
||||
"graph_span": "30d",
|
||||
"apex_config": {
|
||||
"chart": {
|
||||
"type": "bar",
|
||||
"height": 300
|
||||
},
|
||||
"plotOptions": {
|
||||
"bar": {
|
||||
"columnWidth": "50%"
|
||||
}
|
||||
},
|
||||
"dataLabels": {
|
||||
"enabled": false
|
||||
},
|
||||
"yaxis": {
|
||||
"tickAmount": 4,
|
||||
"labels": {
|
||||
"minWidth": 40
|
||||
}
|
||||
}
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"entity": "sensor.octoprint_filament_daily",
|
||||
"name": "Used (mm)",
|
||||
"color": "#26c6da",
|
||||
"type": "column",
|
||||
"group_by": {
|
||||
"func": "last",
|
||||
"duration": "1d",
|
||||
"start_with_last": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_filament_used_total",
|
||||
"name": "Total (mm)",
|
||||
"color": "#ab47bc",
|
||||
"type": "line",
|
||||
"curve": "straight",
|
||||
"group_by": {
|
||||
"func": "last",
|
||||
"duration": "1d",
|
||||
"start_with_last": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"entity": "sensor.octoprint_filament_weekly",
|
||||
"name": "Weekly (mm)",
|
||||
"color": "#ffca28",
|
||||
"type": "column",
|
||||
"group_by": {
|
||||
"func": "last",
|
||||
"duration": "1d",
|
||||
"start_with_last": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"grid_options": {
|
||||
"columns": "full"
|
||||
},
|
||||
"span": {
|
||||
"end": "day"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "grid",
|
||||
"cards": [
|
||||
{
|
||||
"type": "heading",
|
||||
"heading": "OctoPrint Host",
|
||||
"icon": "mdi:server"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_disk_usage",
|
||||
"icon": "mdi:harddisk"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_cpu_usage",
|
||||
"icon": "mdi:cpu-64-bit"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_memory_usage",
|
||||
"icon": "mdi:memory"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_core_0_temperature",
|
||||
"icon": "mdi:thermometer"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_uptime",
|
||||
"icon": "mdi:clock-outline"
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "sensor.octoprint_serial_health_ratio",
|
||||
"name": "Serial Health",
|
||||
"icon": "mdi:heart-pulse",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tile",
|
||||
"entity": "binary_sensor.octoprint_serial_health_critical",
|
||||
"name": "Serial Critical",
|
||||
"icon": "mdi:alert",
|
||||
"grid_options": {
|
||||
"columns": 6
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"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.1.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "53837b51a371459b95ffc0989ce615fb",
|
||||
"url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749360",
|
||||
"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=1785159024",
|
||||
"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=56675817588421",
|
||||
"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.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0796c92931dd458e81dc9c2677248274",
|
||||
"url": "/taskmate/taskmate-localize.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2e281920ac7409583eafff1f1db69bd",
|
||||
"url": "/taskmate/taskmate-design.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9eae9632f0734480be5edd784b030673",
|
||||
"url": "/taskmate/taskmate-badges-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5df49cd8a4eb420497b5df22cb31362c",
|
||||
"url": "/taskmate/taskmate-child-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
|
||||
"url": "/taskmate/taskmate-rewards-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "787a96f9c30548708165a48457c99feb",
|
||||
"url": "/taskmate/taskmate-approvals-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "84031597de424ad68e7c2bc66d06b72e",
|
||||
"url": "/taskmate/taskmate-points-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "02613225844746b699bfbfac9642eca2",
|
||||
"url": "/taskmate/taskmate-reorder-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "7a06a07e17ba4b31b712bc91b77360b4",
|
||||
"url": "/taskmate/taskmate-overview-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "08b42b53b33b4fa88db932455cf8d5aa",
|
||||
"url": "/taskmate/taskmate-activity-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "15907eff51784acdba88ff694b574509",
|
||||
"url": "/taskmate/taskmate-streak-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
|
||||
"url": "/taskmate/taskmate-weekly-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "289ce5943f5a4972a593697db4c96ece",
|
||||
"url": "/taskmate/taskmate-graph-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2904acee4c74a398dcc7b996c23e7e7",
|
||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "46d378736fd54a26be253f2dfb27202e",
|
||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "db11ae36457842fa826c7991e1d9c6f4",
|
||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
|
||||
"url": "/taskmate/taskmate-penalties-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1959e1629de2422daa8adaf87069c288",
|
||||
"url": "/taskmate/taskmate-bonuses-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "2548524b269c4959bc7e0e967df943af",
|
||||
"url": "/taskmate/taskmate-points-display-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "36ada49368db4187a283fbbbef45454f",
|
||||
"url": "/taskmate/taskmate-calendar-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "67c32687acd2497b90030ef141f4b6db",
|
||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
|
||||
"url": "/taskmate/taskmate-family-goal-card.js?v=5.1.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "e92ef0caff41454e9f49ea966ed99e41",
|
||||
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374101",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "4d1a17aa96b74064af713fc5e9334862",
|
||||
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680152",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d3c3e144a91247ce8ac524814737d7f8",
|
||||
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164660",
|
||||
"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=1230152807420",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9a17b155e3fb42949f2b2f64e24c42d5",
|
||||
"url": "/hacsfiles/mass-player-card/mass-player-card.js?hacstag=1043277514280",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "e635fcf579d24ceb97758cc125ed5664",
|
||||
"url": "/my_music_library/my-music-library-card.js?v=3.10.4",
|
||||
"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.1.1",
|
||||
"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": "488d448822ab42869df9a07fbb87d1ed",
|
||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.7.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "8202c7b2a7bd4c3e87e57bcd3c97e818",
|
||||
"url": "/local/gcode_viewer_card.js",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "f42c42b045bf41109dcd98a0cb6bffdf",
|
||||
"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,34 @@
|
||||
{
|
||||
"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,
|
||||
"author_info": {
|
||||
"name": "Daniel O'Connor",
|
||||
"email": "daniel.oconnor@gmail.com",
|
||||
"github": "CloCkWeRX"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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,
|
||||
"author_info": {
|
||||
"name": "giu889",
|
||||
"github": "giu889"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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,
|
||||
"author_info": {
|
||||
"name": "Peklaa",
|
||||
"github": "Peklaa"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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,
|
||||
"author_info": {
|
||||
"name": "martinkura-svk",
|
||||
"github": "martinkura-svk"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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,
|
||||
"author_info": {
|
||||
"name": "schuppan",
|
||||
"github": "schuppan"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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,
|
||||
"author_info": {
|
||||
"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.
|
||||
@@ -47,6 +47,41 @@ 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)
|
||||
|
||||
## 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:
|
||||
@@ -82,6 +117,7 @@ Home Assistant is developing a native `llm` integration where Core integrations
|
||||
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}'`
|
||||
- **Service calls that answer with data**: add `--return-response`, e.g. `hab action call weather.get_forecasts --entity weather.home --data '{"type":"daily"}' --return-response`. Without the flag Home Assistant refuses the call outright
|
||||
- **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"`
|
||||
@@ -668,12 +704,16 @@ Read and modify YAML files to understand and change Home Assistant's defined beh
|
||||
### 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
|
||||
- `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` - Historical data; supplied timestamps must include `Z` or a UTC offset
|
||||
- `get_calendar_events` - Calendar events; supplied start/end timestamps must include `Z` or a UTC offset
|
||||
- `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
|
||||
- `get_supervisor_health`, `get_supervisor_resolution` - Read-only Supervisor, host, connectivity, and repair evidence
|
||||
- `get_backup_posture`, `get_store_audit`, `get_supervisor_metrics` - Bounded backup, software-source, and resource evidence
|
||||
- `get_support_logs` - Bounded, credential-redacted Core, Supervisor, host, or app logs
|
||||
- `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
|
||||
@@ -689,8 +729,9 @@ Query and interact with the running Home Assistant instance:
|
||||
| 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 |
|
||||
| Read from a service that answers with data | N/A | `call_service` (automatic) | `hab action call --return-response` | 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 |
|
||||
| Troubleshoot issues | Review configs | `diagnose_entity`, `get_error_log`, `get_supervisor_health`, `get_supervisor_resolution` | `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 |
|
||||
@@ -699,7 +740,7 @@ Query and interact with the running Home Assistant instance:
|
||||
| **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 |
|
||||
| **Backups** | N/A | `get_backup_posture` | **`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 |
|
||||
|
||||
@@ -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
|
||||
+481
-162
@@ -2,7 +2,7 @@
|
||||
alias: 'Tablet: Dim Screen at Night'
|
||||
description: Drops tablet brightness to zero at night
|
||||
triggers:
|
||||
- at: 01:00:00
|
||||
- at: '23:30:00'
|
||||
trigger: time
|
||||
actions:
|
||||
- action: notify.mobile_app_sm_t387w
|
||||
@@ -40,7 +40,6 @@
|
||||
metadata: {}
|
||||
target:
|
||||
device_id:
|
||||
- 3e67afd88a4b4d17552c8f370aaf40b5
|
||||
- baad176ac2f415f3642e2d075bb6a977
|
||||
data: {}
|
||||
mode: single
|
||||
@@ -223,6 +222,10 @@
|
||||
minutes: 0
|
||||
seconds: 0
|
||||
id: turn_off_logic
|
||||
- trigger: state
|
||||
entity_id: input_boolean.office_keep_lights_on
|
||||
to: 'on'
|
||||
id: keep_on_enabled
|
||||
conditions: []
|
||||
actions:
|
||||
- choose:
|
||||
@@ -258,6 +261,9 @@
|
||||
- condition: state
|
||||
entity_id: light.playroom_light
|
||||
state: 'on'
|
||||
- condition: state
|
||||
entity_id: input_boolean.office_keep_lights_on
|
||||
state: 'off'
|
||||
sequence:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
@@ -267,6 +273,14 @@
|
||||
data:
|
||||
message: Office Lights Turned Off due to inactivity
|
||||
title: ⚡️ Office Lights
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: keep_on_enabled
|
||||
sequence:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.playroom_light
|
||||
data: {}
|
||||
mode: restart
|
||||
- id: '1781034724187'
|
||||
alias: 'Office: Turn Off Lights on Exit'
|
||||
@@ -283,6 +297,9 @@
|
||||
- condition: template
|
||||
value_template: '{{ states.binary_sensor.basement.last_changed > states.binary_sensor.office_door_contact.last_changed
|
||||
}}'
|
||||
- condition: state
|
||||
entity_id: input_boolean.office_keep_lights_on
|
||||
state: 'off'
|
||||
actions:
|
||||
- delay:
|
||||
minutes: 2
|
||||
@@ -712,12 +729,11 @@
|
||||
alias: Basement Temperature Change
|
||||
description: ''
|
||||
triggers:
|
||||
- type: temperature
|
||||
device_id: 3e67afd88a4b4d17552c8f370aaf40b5
|
||||
entity_id: 64689573a8b11a83829efcae0022d7c1
|
||||
domain: sensor
|
||||
trigger: device
|
||||
- trigger: numeric_state
|
||||
entity_id: sensor.basement_temperature
|
||||
above: 15
|
||||
- trigger: numeric_state
|
||||
entity_id: sensor.basement_temperature
|
||||
below: 10
|
||||
conditions: []
|
||||
actions:
|
||||
@@ -935,12 +951,7 @@
|
||||
notification_id: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.blink_backyard_shed
|
||||
data:
|
||||
filename: /config/www/{{ snapshot_filename }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.blink_backyard_shed
|
||||
entity_id: camera.blink_backyard
|
||||
data:
|
||||
filename: /media/{{ snapshot_filename }}
|
||||
- delay:
|
||||
@@ -951,8 +962,8 @@
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Blink Shed Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
task_name: Blink Backyard Camera Analysis
|
||||
entity_id: ai_task.ollama_ai_task_video
|
||||
instructions: Describe what you see in this image in brief, but make it a little
|
||||
humorous. Focus on any people, objects, animals or activities. Text needs
|
||||
to be max 240 characters.
|
||||
@@ -1006,15 +1017,15 @@
|
||||
- delay: 00:00:02
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
response_variable: ai_profile
|
||||
data:
|
||||
task_name: Backyard Shed Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
task_name: Blink InShed Camera Analysis
|
||||
entity_id: ai_task.ollama_ai_task_video
|
||||
instructions: Describe what you see in this image in brief. Focus on any people,
|
||||
objects, or activities. Text needs to be max 225 characters.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/blinkTree.jpg
|
||||
media_content_type: image/jpeg
|
||||
response_variable: ai_profile
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: Motion Detected - Backyard Tree
|
||||
@@ -1579,12 +1590,10 @@
|
||||
}}'
|
||||
- action: persistent_notification.create
|
||||
data:
|
||||
title: Motion Detected - Backyard Inside Shed
|
||||
title: Motion Detected - Backyard
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}
|
||||
|
||||
'
|
||||
}}'
|
||||
notification_id: '{{ notification_id }}'
|
||||
mode: queued
|
||||
max: 10
|
||||
@@ -1884,12 +1893,12 @@
|
||||
path: stone13/ikea_bilresa_atomic_v1.yaml
|
||||
input:
|
||||
light_entity: light.bedroom_lights
|
||||
sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1
|
||||
sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2
|
||||
sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4
|
||||
sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5
|
||||
sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7
|
||||
sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8
|
||||
sensor_1: sensor.bedroom_switch_current_switch_position_1
|
||||
sensor_2: sensor.bedroom_switch_current_switch_position_2
|
||||
sensor_4: sensor.bedroom_switch_current_switch_position_4
|
||||
sensor_5: sensor.bedroom_switch_current_switch_position_5
|
||||
sensor_7: sensor.bedroom_switch_current_switch_position_7
|
||||
sensor_8: sensor.office_scroll_wheel_current_switch_position_8
|
||||
event_taste_1: event.bedroom_switch_button_3
|
||||
event_taste_2: event.bedroom_switch_button_6
|
||||
event_taste_3: event.bedroom_switch_button_9
|
||||
@@ -1953,41 +1962,6 @@
|
||||
device_id: 2f8c5ab2ca0d4be31919fba6df365813
|
||||
data: {}
|
||||
mode: single
|
||||
- id: '1783915438772'
|
||||
alias: IKEA Bilresa Scrollwheel TEST FRANCO - Light Control
|
||||
description: ''
|
||||
use_blueprint:
|
||||
path: thetestspecimen/ikea-bilresa-scroll-wheel.yaml
|
||||
input:
|
||||
event_button_1: event.bilresa_switch_button_3
|
||||
event_button_2: event.bilresa_switch_button_6
|
||||
event_button_3: event.bilresa_switch_button_9
|
||||
sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1_2
|
||||
sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2_2
|
||||
sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4_2
|
||||
sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5_2
|
||||
sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7_2
|
||||
sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8_2
|
||||
global_light:
|
||||
- light.living_room_living_room_home_living_room
|
||||
- id: '1783960311392'
|
||||
alias: Ikea_bilresa_scroll_wheel TEST
|
||||
description: ''
|
||||
use_blueprint:
|
||||
path: thetestspecimen/ikea-bilresa-scroll-wheel.yaml
|
||||
input:
|
||||
event_button_1: event.bilresa_scroll_wheel_button_3
|
||||
event_button_2: event.bedroom_switch_button_6
|
||||
event_button_3: event.bilresa_scroll_wheel_button_9
|
||||
sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1
|
||||
sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2
|
||||
sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4
|
||||
sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5
|
||||
sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7
|
||||
sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8
|
||||
global_light:
|
||||
- light.playroom_light
|
||||
light_z1: []
|
||||
- id: '1783960650360'
|
||||
alias: 'Bedroom: Ikea Bilresa Scrollwheel - Light Control'
|
||||
description: ''
|
||||
@@ -2019,6 +1993,24 @@
|
||||
data:
|
||||
brightness_pct: 25
|
||||
dim_turns_lights_on: true
|
||||
click_action_ch2:
|
||||
- action: light.toggle
|
||||
metadata: {}
|
||||
target:
|
||||
entity_id: light.bedroom_lights
|
||||
data: {}
|
||||
scroll_wheel_target_ch2:
|
||||
- light.bedroom_lights
|
||||
scroll_wheel_mode_ext_ch2: instant
|
||||
click_action_ch3:
|
||||
- action: light.toggle
|
||||
metadata: {}
|
||||
target:
|
||||
entity_id: light.bedroom_lights
|
||||
data: {}
|
||||
scroll_wheel_target_ch3:
|
||||
- light.bedroom_lights
|
||||
scroll_wheel_mode_ext_ch3: instant
|
||||
- id: '1783962246983'
|
||||
alias: 'OFFICE: IKEA BILRESA E2489 Dual Button (Matter) - In Meeting and Away Mode'
|
||||
description: Ikea BILRESA E2489 Dual Button controls in Meeting toggle, and enable
|
||||
@@ -2041,7 +2033,42 @@
|
||||
target:
|
||||
entity_id: input_boolean.away_mode
|
||||
data: {}
|
||||
button2_double: []
|
||||
button2_double:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: '{{ states(''input_text.office_lamp_previous_color'')
|
||||
== '''' }}'
|
||||
sequence:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.office_lamp
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.office_lamp_previous_color
|
||||
data:
|
||||
value: '{{ state_attr(''light.office_lamp'', ''rgb_color'') | join('','')
|
||||
}}'
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.office_lamp
|
||||
data:
|
||||
rgb_color:
|
||||
- 135
|
||||
- 206
|
||||
- 250
|
||||
default:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.office_lamp
|
||||
data:
|
||||
rgb_color: '{{ states(''input_text.office_lamp_previous_color'').split('','')
|
||||
| map(''int'') | list }}'
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.office_lamp_previous_color
|
||||
data:
|
||||
value: ''
|
||||
button1_long_press: []
|
||||
button2_long_press: []
|
||||
- id: '1783972932942'
|
||||
@@ -2156,16 +2183,10 @@
|
||||
- action: light.toggle
|
||||
metadata: {}
|
||||
target:
|
||||
entity_id: light.office_lamp
|
||||
data: {}
|
||||
- action: light.toggle
|
||||
metadata: {}
|
||||
target:
|
||||
device_id: 2f8c5ab2ca0d4be31919fba6df365813
|
||||
entity_id: light.bedroom_lamp
|
||||
data: {}
|
||||
scroll_wheel_target_ch3:
|
||||
- light.office_lamp
|
||||
- light.playroom_light
|
||||
- light.bedroom_lamp
|
||||
scroll_wheel_mode_ext_ch3: instant
|
||||
on_hold_action_ch1:
|
||||
- action: light.turn_on
|
||||
@@ -2177,11 +2198,17 @@
|
||||
dim_turns_lights_on: true
|
||||
- id: '1784073759556'
|
||||
alias: 'Office: Desk Lamp Meeting Alert'
|
||||
description: Flashes the desk lamp color temperature for a few seconds when a meeting
|
||||
status changes (on or off) to confirm the button press registered.
|
||||
description: Flashes the desk lamp red when meeting starts, green when it ends,
|
||||
then returns to original state.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
to: 'on'
|
||||
id: meeting_on
|
||||
- trigger: state
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
to: 'off'
|
||||
id: meeting_off
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: input_boolean.meeting_lamp_override
|
||||
@@ -2190,36 +2217,67 @@
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'off'
|
||||
actions:
|
||||
- action: scene.create
|
||||
data:
|
||||
scene_id: desk_lamp_before_alert
|
||||
snapshot_entities:
|
||||
- light.office_lamp
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.office_lamp
|
||||
data:
|
||||
brightness_pct: 100
|
||||
color_temp_kelvin: 2261
|
||||
transition: 1
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 2
|
||||
milliseconds: 0
|
||||
- action: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.desk_lamp_before_alert
|
||||
data:
|
||||
transition: 1
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: meeting_on
|
||||
sequence:
|
||||
- action: scene.create
|
||||
data:
|
||||
scene_id: desk_lamp_before_alert
|
||||
snapshot_entities:
|
||||
- light.office_lamp
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.office_lamp
|
||||
data:
|
||||
brightness_pct: 100
|
||||
hs_color:
|
||||
- 0
|
||||
- 100
|
||||
transition: 0.5
|
||||
- delay:
|
||||
seconds: 1.5
|
||||
- action: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.desk_lamp_before_alert
|
||||
data:
|
||||
transition: 1
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: meeting_off
|
||||
sequence:
|
||||
- action: scene.create
|
||||
data:
|
||||
scene_id: desk_lamp_before_alert
|
||||
snapshot_entities:
|
||||
- light.office_lamp
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.office_lamp
|
||||
data:
|
||||
brightness_pct: 100
|
||||
hs_color:
|
||||
- 120
|
||||
- 100
|
||||
transition: 0.5
|
||||
- delay:
|
||||
seconds: 1.5
|
||||
- action: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.desk_lamp_before_alert
|
||||
data:
|
||||
transition: 1
|
||||
default: []
|
||||
mode: queued
|
||||
max: 2
|
||||
- id: '1784153893584'
|
||||
alias: 'Office: Manual Meeting Status Busy Light (Night Light) - Working Hours +
|
||||
Motion Detection - v2'
|
||||
description: 'Work hours: Automatically turns Green or Red at 8 AM depending on
|
||||
status. Toggle controls Red/Green. After hours OR Away Mode: Pure motion night
|
||||
light. Instant transition when Away changes. Only triggers motion if dark.'
|
||||
status. Toggle controls Red/Green. At 6 PM, light clears and switches to motion
|
||||
detection. After hours OR Away Mode: Pure motion night light. Instant transition
|
||||
when Away changes. Only triggers motion if dark.'
|
||||
triggers:
|
||||
- id: status_changed
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
@@ -2238,6 +2296,9 @@
|
||||
- id: work_day_started
|
||||
trigger: time
|
||||
at: 08:00:00
|
||||
- id: work_day_ended
|
||||
trigger: time
|
||||
at: '18:00:00'
|
||||
conditions: []
|
||||
actions:
|
||||
- variables:
|
||||
@@ -2284,6 +2345,14 @@
|
||||
- 5
|
||||
- 245
|
||||
- 45
|
||||
- alias: '--- WORK DAY ENDED: CLEAR LIGHT ---'
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: '{{ trigger.id == ''work_day_ended'' }}'
|
||||
sequence:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: light.night_light
|
||||
- alias: '--- AFTER HOURS / AWAY: MOTION DETECTED ---'
|
||||
conditions:
|
||||
- condition: template
|
||||
@@ -2455,15 +2524,18 @@
|
||||
- camera_person
|
||||
enabled: false
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: '{{ (now() - state_attr(this.entity_id, ''last_triggered'')).total_seconds()
|
||||
> 60 }}'
|
||||
- condition: template
|
||||
value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'',
|
||||
''person'', ''animal'', ''chime''] }}'
|
||||
enabled: false
|
||||
actions:
|
||||
- action: shell_command.cleanup_blink_snapshots
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.backyard_refresh_snapshot
|
||||
data: {}
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
@@ -2473,7 +2545,9 @@
|
||||
target:
|
||||
entity_id: camera.blink_backyard
|
||||
enabled: true
|
||||
data: {}
|
||||
data:
|
||||
entity_id:
|
||||
- camera.blink_backyard
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
@@ -2513,10 +2587,9 @@
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Blink Backyard Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief, but make it a little
|
||||
humorous. Focus on any people, objects, animals or activities. Text needs
|
||||
to be max 240 characters.
|
||||
entity_id: ai_task.ollama_ai_task_video
|
||||
instructions: Describe what you see in this image in brief, but make it a very
|
||||
funny. Focus on any people, objects, animals or activities.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/{{ snapshot_filename }}
|
||||
media_content_type: image/jpeg
|
||||
@@ -2590,15 +2663,18 @@
|
||||
- 'on'
|
||||
enabled: true
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: '{{ (now() - state_attr(this.entity_id, ''last_triggered'')).total_seconds()
|
||||
> 60 }}'
|
||||
- condition: template
|
||||
value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'',
|
||||
''person'', ''animal'', ''chime''] }}'
|
||||
enabled: false
|
||||
actions:
|
||||
- action: shell_command.cleanup_blink_snapshots
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.front_refresh_snapshot
|
||||
data: {}
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
@@ -2608,7 +2684,9 @@
|
||||
target:
|
||||
entity_id: camera.front
|
||||
enabled: true
|
||||
data: {}
|
||||
data:
|
||||
entity_id:
|
||||
- camera.front
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
@@ -2631,12 +2709,12 @@
|
||||
notification_id: blink_front_{{ now().strftime('%Y%m%d_%H%M%S_%f') }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.front
|
||||
entity_id: camera.blink_front
|
||||
data:
|
||||
filename: /media/{{ snapshot_filename }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.front
|
||||
entity_id: camera.blink_front
|
||||
data:
|
||||
filename: /config/www/{{ snapshot_filename }}
|
||||
- delay:
|
||||
@@ -2647,11 +2725,10 @@
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Nest Front Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief, but make it a little
|
||||
humorous. Focus on any people, objects, animals or activities. Text needs
|
||||
to be max 240 characters.
|
||||
task_name: Blink Front Camera Analysis
|
||||
entity_id: ai_task.ollama_ai_task_video
|
||||
instructions: Describe what you see in this image in brief, but make it a very
|
||||
funny. Focus on any people, objects, animals or activities.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/{{ snapshot_filename }}
|
||||
media_content_type: image/jpeg
|
||||
@@ -2663,7 +2740,7 @@
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}'
|
||||
- action: notify.persistent_notification
|
||||
- action: persistent_notification.create
|
||||
data:
|
||||
title: Motion Detected - Front Door
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
@@ -2671,24 +2748,18 @@
|
||||
}}
|
||||
|
||||
'
|
||||
data:
|
||||
notification_id: nest_front_motion
|
||||
enabled: false
|
||||
- action: persistent_notification.create
|
||||
metadata: {}
|
||||
data:
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}} '
|
||||
title: Motion Detected - Front Door
|
||||
notification_id: '{{ notification_id }}'
|
||||
mode: queued
|
||||
max: 10
|
||||
- id: '1784601274723'
|
||||
alias: Motion Shed - AI Description - Version 2
|
||||
description: Takes a snapshot when the Nest camera detects motion, a person, an
|
||||
animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent
|
||||
notification.
|
||||
description: Takes a snapshot when the shed's Blink camera detects motion, analyzes
|
||||
it with AI, and sends a mobile and persistent notification. Structurally mirrors
|
||||
the working Front automation (same switch/camera device pairing). If the AI description
|
||||
still doesn't match the physical shed, camera.backyard_shed may be mislabeled
|
||||
in the HomeKit Controller integration -- check its live feed against camera.garage_side
|
||||
and camera.backyard_tree to confirm which physical camera is which, then rename
|
||||
if needed.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: input_boolean.blink_shed_camera_motion
|
||||
@@ -2734,34 +2805,39 @@
|
||||
- camera_person
|
||||
enabled: false
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: '{{ (now() - state_attr(this.entity_id, ''last_triggered'')).total_seconds()
|
||||
> 60 }}'
|
||||
- condition: template
|
||||
value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'',
|
||||
''person'', ''animal'', ''chime''] }}'
|
||||
enabled: false
|
||||
actions:
|
||||
- action: shell_command.cleanup_blink_snapshots
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.backyard_shed_refresh_snapshot
|
||||
data: {}
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 20
|
||||
seconds: 30
|
||||
milliseconds: 0
|
||||
- action: homeassistant.update_entity
|
||||
target:
|
||||
entity_id: camera.backyard_shed
|
||||
entity_id: camera.blink_backyard_shed
|
||||
enabled: true
|
||||
data: {}
|
||||
data:
|
||||
entity_id:
|
||||
- camera.blink_backyard_shed
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 3
|
||||
seconds: 5
|
||||
milliseconds: 0
|
||||
- action: camera.record
|
||||
target:
|
||||
entity_id:
|
||||
- camera.backyard_shed
|
||||
- camera.blink_backyard_shed
|
||||
data:
|
||||
duration: 10
|
||||
lookback: 0
|
||||
@@ -2776,17 +2852,17 @@
|
||||
- variables:
|
||||
snapshot_filename: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg
|
||||
- variables:
|
||||
notification_id: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }}
|
||||
notification_id: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S_%f') }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_shed
|
||||
data:
|
||||
filename: /config/www/{{ snapshot_filename }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_shed
|
||||
entity_id: camera.blink_backyard_shed
|
||||
data:
|
||||
filename: /media/{{ snapshot_filename }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.blink_backyard_shed
|
||||
data:
|
||||
filename: /config/www/{{ snapshot_filename }}
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
@@ -2794,16 +2870,15 @@
|
||||
milliseconds: 0
|
||||
- action: ai_task.generate_data
|
||||
continue_on_error: true
|
||||
response_variable: ai_profile
|
||||
data:
|
||||
task_name: Blink Shed Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief, but make it a little
|
||||
humorous. Focus on any people, objects, animals or activities. Text needs
|
||||
to be max 240 characters.
|
||||
entity_id: ai_task.ollama_ai_task_video
|
||||
instructions: Describe what you see in this image in brief, but make it a very
|
||||
funny. Focus on any people, objects, animals or activities.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/{{ snapshot_filename }}
|
||||
media_content_type: image/jpeg
|
||||
response_variable: ai_profile
|
||||
- action: notify.notify
|
||||
continue_on_error: true
|
||||
data:
|
||||
@@ -2824,9 +2899,10 @@
|
||||
max: 10
|
||||
- id: '1784601377055'
|
||||
alias: Motion Garage - AI Description - Version 2
|
||||
description: Takes a snapshot when the Nest camera detects motion, a person, an
|
||||
animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent
|
||||
notification.
|
||||
description: Takes a snapshot when garage motion is detected and analyzes it with
|
||||
AI. Deliberately uses the 'backyard_tree' Blink camera (switch.backyard_tree_refresh_snapshot
|
||||
/ camera.backyard_tree) as the garage-monitoring camera -- entity names don't
|
||||
match the physical location, but this pairing is intentional, not a bug.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
@@ -2874,33 +2950,38 @@
|
||||
- camera_person
|
||||
enabled: false
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: '{{ (now() - state_attr(this.entity_id, ''last_triggered'')).total_seconds()
|
||||
> 60 }}'
|
||||
- condition: template
|
||||
value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'',
|
||||
''person'', ''animal'', ''chime''] }}'
|
||||
enabled: false
|
||||
actions:
|
||||
- action: shell_command.cleanup_blink_snapshots
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.backyard_tree_refresh_snapshot
|
||||
data: {}
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 20
|
||||
seconds: 30
|
||||
milliseconds: 0
|
||||
- action: homeassistant.update_entity
|
||||
target:
|
||||
entity_id: camera.backyard_tree
|
||||
entity_id: camera.blink_backyard_tree
|
||||
enabled: true
|
||||
data: {}
|
||||
data:
|
||||
entity_id:
|
||||
- camera.blink_backyard_tree
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 3
|
||||
seconds: 5
|
||||
milliseconds: 0
|
||||
- action: blink.trigger_camera
|
||||
target:
|
||||
entity_id: camera.backyard_tree
|
||||
entity_id: camera.blink_backyard_tree
|
||||
data: {}
|
||||
enabled: false
|
||||
- delay:
|
||||
@@ -2915,12 +2996,12 @@
|
||||
notification_id: blink_Inshed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_tree
|
||||
entity_id: camera.blink_backyard_tree
|
||||
data:
|
||||
filename: /media/{{ snapshot_filename }}
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.backyard_tree
|
||||
entity_id: camera.blink_backyard_tree
|
||||
data:
|
||||
filename: /config/www/{{ snapshot_filename }}
|
||||
- delay:
|
||||
@@ -2932,10 +3013,9 @@
|
||||
continue_on_error: true
|
||||
data:
|
||||
task_name: Blink InShed Camera Analysis
|
||||
entity_id: ai_task.google_ai_task
|
||||
instructions: Describe what you see in this image in brief, but make it a little
|
||||
humorous. Focus on any people, objects, animals or activities. Text needs
|
||||
to be max 240 characters.
|
||||
entity_id: ai_task.ollama_ai_task_video
|
||||
instructions: Describe what you see in this image in brief, but make it a very
|
||||
funny. Focus on any people, objects, animals or activities.
|
||||
attachments:
|
||||
- media_content_id: media-source://media_source/local/{{ snapshot_filename }}
|
||||
media_content_type: image/jpeg
|
||||
@@ -2943,13 +3023,13 @@
|
||||
- action: notify.notify
|
||||
continue_on_error: true
|
||||
data:
|
||||
title: Motion Detected - Backyard Inside Shed
|
||||
title: Motion Detected - Garage
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}'
|
||||
- action: persistent_notification.create
|
||||
data:
|
||||
title: Motion Detected - Backyard Inside Shed
|
||||
title: Motion Detected - Garage
|
||||
message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile
|
||||
else ''Motion detected, but the AI Task integration failed to return a response.''
|
||||
}}
|
||||
@@ -2958,3 +3038,242 @@
|
||||
notification_id: '{{ notification_id }}'
|
||||
mode: queued
|
||||
max: 10
|
||||
- id: '1785027869879'
|
||||
alias: Alfred Lock - Silent Force Hardware Sync
|
||||
description: Mutes living room Echo, forces Alexa to check physical lock state,
|
||||
and restores volume.
|
||||
triggers:
|
||||
- minutes: /15
|
||||
trigger: time_pattern
|
||||
actions:
|
||||
- action: scene.create
|
||||
data:
|
||||
scene_id: echo_volume_snapshot
|
||||
snapshot_entities:
|
||||
- media_player.living_room_1_2
|
||||
- action: media_player.volume_set
|
||||
target:
|
||||
entity_id: media_player.living_room_1_2
|
||||
data:
|
||||
volume_level: 0
|
||||
- action: media_player.play_media
|
||||
target:
|
||||
entity_id: media_player.living_room_1_2
|
||||
data:
|
||||
media:
|
||||
media_content_id: Is the front door locked?
|
||||
media_content_type: custom
|
||||
metadata: {}
|
||||
- delay:
|
||||
seconds: 5
|
||||
- action: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.echo_volume_snapshot
|
||||
mode: single
|
||||
- id: '1785108022773'
|
||||
alias: Alfred Lock - Auto Sync on Door Contact
|
||||
description: Forces a lock state resync whenever the front door opens or closes.
|
||||
triggers:
|
||||
- entity_id: binary_sensor.front_door
|
||||
to:
|
||||
- open
|
||||
- 'off'
|
||||
trigger: state
|
||||
actions:
|
||||
- action: homeassistant.update_entity
|
||||
target:
|
||||
entity_id: input_boolean.alfred_lock_sync_switch
|
||||
- action: homeassistant.update_entity
|
||||
target:
|
||||
entity_id: lock.alfred_front_door
|
||||
mode: single
|
||||
- id: doorbell_flash_kitchen_lights
|
||||
alias: 'Doorbell: Flash Kitchen Lights'
|
||||
description: Flashes all kitchen lights when the front doorbell rings, then restores
|
||||
their original state (on or off).
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: event.front_door_front_door_chime
|
||||
conditions: []
|
||||
actions:
|
||||
- action: scene.create
|
||||
data:
|
||||
scene_id: kitchen_lights_before_doorbell
|
||||
snapshot_entities:
|
||||
- light.kitchen_group_kitchen_group_home_kitchen_sink_light
|
||||
- light.kitchen_island_switch
|
||||
- light.kitchen_kitchen_home_kitchen
|
||||
- light.kitchen_kitchen_home_kitchen_group
|
||||
- light.kitchen_spotlight_switch
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id:
|
||||
- light.kitchen_group_kitchen_group_home_kitchen_sink_light
|
||||
- light.kitchen_island_switch
|
||||
- light.kitchen_kitchen_home_kitchen
|
||||
- light.kitchen_kitchen_home_kitchen_group
|
||||
- light.kitchen_spotlight_switch
|
||||
data:
|
||||
flash: long
|
||||
- delay:
|
||||
seconds: 5
|
||||
- action: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.kitchen_lights_before_doorbell
|
||||
data:
|
||||
transition: 1
|
||||
mode: single
|
||||
- id: '1785339249610'
|
||||
alias: 'TEST: Nest Snapshot Capture'
|
||||
description: Manual test for nest_snapshot.capture. Trigger from Developer Tools
|
||||
after restart.
|
||||
triggers: []
|
||||
actions:
|
||||
- action: nest_snapshot.capture
|
||||
data:
|
||||
entity_id: camera.front_door_front_door
|
||||
filename: /media/nest_snapshot_test_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg
|
||||
mode: single
|
||||
- id: '1785688553432'
|
||||
alias: 'Basement: Switch Toggles Living Room Lights'
|
||||
description: Ikea BILRESA E2489 Dual Button (Basement Switch) toggles the Dining
|
||||
Room Switch light on Button 1 single press. Button 2 single press dims to 30%
|
||||
and restores the previous brightness.
|
||||
use_blueprint:
|
||||
path: censay/ikea-bilresa-e2489-matter-smart-button.yaml
|
||||
input:
|
||||
target_device: 2101b0e6d9ae7b4b2e1cb727a9ed42f0
|
||||
button1_event: event.basement_switch_button_1
|
||||
button2_event: event.basement_switch_button_2
|
||||
button1_single:
|
||||
- action: light.toggle
|
||||
metadata: {}
|
||||
target:
|
||||
entity_id: light.living_room_living_room_home_living_room
|
||||
data: {}
|
||||
button2_single:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: '{{ states(''input_text.living_room_previous_brightness'')
|
||||
in ['''', ''unknown'', ''unavailable'', none] }}'
|
||||
sequence:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room_living_room_home_living_room
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.living_room_previous_brightness
|
||||
data:
|
||||
value: '{{ state_attr(''light.living_room_living_room_home_living_room'',
|
||||
''brightness'') }}'
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room_living_room_home_living_room
|
||||
data:
|
||||
brightness_pct: 30
|
||||
default:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.living_room_living_room_home_living_room
|
||||
data:
|
||||
brightness: '{{ states(''input_text.living_room_previous_brightness'')
|
||||
| int }}'
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.living_room_previous_brightness
|
||||
data:
|
||||
value: ''
|
||||
- id: '1786068561172'
|
||||
alias: 3D Printer Running - Playroom Light & Basement Heat
|
||||
description: When the 3D printer running helper turns on (print started), set the
|
||||
3D printer lights to bright white, turn on the bedroom lamp, and set the basement
|
||||
to 22 degrees C. When it turns off (print ended), set the 3D printer lights to
|
||||
light blue at 25% and turn off the bedroom lamp after 10 minutes.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: input_boolean.3d_printer_running
|
||||
to: 'on'
|
||||
id: printer_started
|
||||
- trigger: state
|
||||
entity_id: input_boolean.3d_printer_running
|
||||
to: 'off'
|
||||
id: printer_stopped
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: printer_started
|
||||
sequence:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.3d_printer_lights
|
||||
data:
|
||||
brightness_pct: 100
|
||||
rgb_color:
|
||||
- 255
|
||||
- 255
|
||||
- 255
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.bedroom_lamp
|
||||
- action: climate.set_temperature
|
||||
target:
|
||||
entity_id: climate.basement
|
||||
data:
|
||||
temperature: 22
|
||||
hvac_mode: heat
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: printer_stopped
|
||||
sequence:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.3d_printer_lights
|
||||
data:
|
||||
brightness_pct: 25
|
||||
rgb_color:
|
||||
- 173
|
||||
- 216
|
||||
- 230
|
||||
- delay:
|
||||
minutes: 10
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: light.bedroom_lamp
|
||||
mode: restart
|
||||
- id: '1786107704090'
|
||||
alias: Count OctoPrint Print Outcomes
|
||||
description: Increments counter.octoprint_successful_prints on PrintDone and counter.octoprint_failed_prints
|
||||
on PrintFailed/PrintCancelled, triggered by sensor.octoprint_last_event state
|
||||
changes.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: sensor.octoprint_last_event
|
||||
to: PrintDone
|
||||
id: done
|
||||
- trigger: state
|
||||
entity_id: sensor.octoprint_last_event
|
||||
to: PrintFailed
|
||||
id: failed
|
||||
- trigger: state
|
||||
entity_id: sensor.octoprint_last_event
|
||||
to: PrintCancelled
|
||||
id: failed
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: done
|
||||
sequence:
|
||||
- action: counter.increment
|
||||
target:
|
||||
entity_id: counter.octoprint_successful_prints
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: failed
|
||||
sequence:
|
||||
- action: counter.increment
|
||||
target:
|
||||
entity_id: counter.octoprint_failed_prints
|
||||
mode: single
|
||||
|
||||
+220
-6
@@ -1,9 +1,19 @@
|
||||
# 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"
|
||||
|
||||
# Load ffmpeg
|
||||
ffmpeg:
|
||||
|
||||
@@ -171,12 +181,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 +253,162 @@ 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"
|
||||
- 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(1)
|
||||
}}
|
||||
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"
|
||||
|
||||
- name: "Serial Health Ratio"
|
||||
unique_id: octoprint_serial_health_ratio
|
||||
state_topic: "octoPrint/progress/printing"
|
||||
value_template: "{{ value_json.printer_data.health.ratio | float(0) }}"
|
||||
unit_of_measurement: "%"
|
||||
icon: mdi:heart-pulse
|
||||
availability:
|
||||
- topic: "octoPrint/mqtt"
|
||||
payload_available: "connected"
|
||||
payload_not_available: "disconnected"
|
||||
device:
|
||||
identifiers:
|
||||
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||
name: "OctoPrint"
|
||||
|
||||
binary_sensor:
|
||||
- name: "Serial Health Critical"
|
||||
unique_id: octoprint_serial_health_critical
|
||||
state_topic: "octoPrint/progress/printing"
|
||||
value_template: "{{ 'ON' if value_json.printer_data.health.critical else 'OFF' }}"
|
||||
device_class: problem
|
||||
icon: mdi:alert
|
||||
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 +418,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,15 +382,28 @@ 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
|
||||
_LOGGER.debug(
|
||||
"Initial state for %s is %s",
|
||||
entity,
|
||||
new_state,
|
||||
)
|
||||
self.update_ready_to_arm_status(sensor_config["area"])
|
||||
return
|
||||
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,
|
||||
new_state,
|
||||
)
|
||||
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()
|
||||
}
|
||||
|
||||
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.
@@ -52,8 +52,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 +87,8 @@ from .const import (
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -108,6 +136,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 +168,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 +188,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 +202,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 +220,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 +250,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 +284,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(
|
||||
@@ -345,7 +396,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 +575,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,18 +660,22 @@ 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:
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
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:
|
||||
await client.async_validate()
|
||||
except Exception: # noqa: BLE001 - any failure means bad/expired link
|
||||
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 +683,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 +707,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"
|
||||
|
||||
@@ -44,6 +44,30 @@ from .const import (
|
||||
CONF_ICLOUD_TOKEN,
|
||||
CONF_ICLOUD_IMAGE_SIZE,
|
||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||
CONF_ICLOUD_BACKEND,
|
||||
DEFAULT_ICLOUD_BACKEND,
|
||||
ICLOUD_BACKEND_CLOUDKIT,
|
||||
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,
|
||||
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_ORIGINAL,
|
||||
NEXTCLOUD_PREVIEW_PX,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
@@ -52,6 +76,8 @@ from .const import (
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_PHOTOPRISM,
|
||||
PROVIDER_ICLOUD,
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -410,6 +436,11 @@ _NOMINATIM_TIMEOUT_S = 20
|
||||
_EXIF_BATCH_SAVE = 25
|
||||
_GEOCODE_BATCH_SAVE = 10
|
||||
|
||||
# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first
|
||||
# blocks of the file, but we read the whole thing since Pillow needs a complete
|
||||
# image; this bounds memory for pathological files. 64 MB matches the camera.
|
||||
_NEXTCLOUD_ENRICH_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# Inserted between background-enrichment iterations so the event loop
|
||||
# stays responsive on the fast path (items that are already scanned and
|
||||
# need zero work).
|
||||
@@ -664,53 +695,90 @@ def _read_local_exif(path: Path) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
exif = img.getexif()
|
||||
|
||||
if exif:
|
||||
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
|
||||
_EXIF_TAG_DATETIME
|
||||
)
|
||||
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
|
||||
parsed = _parse_exif_datetime(dt_raw, offset_raw)
|
||||
if parsed is not None:
|
||||
out["captured_at"] = parsed
|
||||
|
||||
# Description can come from IPTC / XMP even when the file has no
|
||||
# EXIF IFD, so this runs regardless of ``exif`` being present.
|
||||
description = _read_photo_description(img, exif)
|
||||
if description:
|
||||
out["description"] = description
|
||||
|
||||
if not exif:
|
||||
return out
|
||||
|
||||
gps = None
|
||||
try:
|
||||
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
|
||||
except Exception:
|
||||
gps = None
|
||||
if gps:
|
||||
lat = _gps_to_decimal(
|
||||
gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF)
|
||||
)
|
||||
lon = _gps_to_decimal(
|
||||
gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF)
|
||||
)
|
||||
if lat is not None and lon is not None:
|
||||
# Null Island guard: GPS chips and some editors stamp
|
||||
# ``(0, 0)`` when the fix is invalid. Treat that as no
|
||||
# location rather than dropping every such photo onto
|
||||
# the equator off the African coast.
|
||||
if abs(lat) < 1e-6 and abs(lon) < 1e-6:
|
||||
return out
|
||||
out["latitude"] = lat
|
||||
out["longitude"] = lon
|
||||
_read_exif_from_image(img, out)
|
||||
except Exception as err:
|
||||
_LOGGER.debug("EXIF: failed to read %s: %s", path, err)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _read_exif_from_image(img: Any, out: dict[str, Any]) -> None:
|
||||
"""Fill ``out`` with capture date / description / GPS from an open image.
|
||||
|
||||
Shared by ``_read_local_exif`` (opens from a filesystem path) and
|
||||
``_read_exif_from_bytes`` (opens from downloaded bytes, e.g. the
|
||||
Nextcloud provider) - both hand this an already-``Image.open``'d image
|
||||
plus a dict pre-seeded with a fallback ``captured_at``.
|
||||
"""
|
||||
exif = img.getexif()
|
||||
|
||||
if exif:
|
||||
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
|
||||
_EXIF_TAG_DATETIME
|
||||
)
|
||||
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
|
||||
parsed = _parse_exif_datetime(dt_raw, offset_raw)
|
||||
if parsed is not None:
|
||||
out["captured_at"] = parsed
|
||||
|
||||
# Description can come from IPTC / XMP even when the file has no EXIF
|
||||
# IFD, so this runs regardless of ``exif`` being present.
|
||||
description = _read_photo_description(img, exif)
|
||||
if description:
|
||||
out["description"] = description
|
||||
|
||||
if not exif:
|
||||
return
|
||||
|
||||
gps = None
|
||||
try:
|
||||
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
|
||||
except Exception:
|
||||
gps = None
|
||||
if gps:
|
||||
lat = _gps_to_decimal(gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF))
|
||||
lon = _gps_to_decimal(gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF))
|
||||
if lat is not None and lon is not None:
|
||||
# Null Island guard: GPS chips and some editors stamp ``(0, 0)``
|
||||
# when the fix is invalid. Treat that as no location rather than
|
||||
# dropping every such photo onto the equator off the African coast.
|
||||
if abs(lat) < 1e-6 and abs(lon) < 1e-6:
|
||||
return
|
||||
out["latitude"] = lat
|
||||
out["longitude"] = lon
|
||||
|
||||
|
||||
def _read_exif_from_bytes(
|
||||
data: bytes, mtime_fallback_ms: int | None
|
||||
) -> dict[str, Any]:
|
||||
"""Read EXIF metadata from already-downloaded image bytes.
|
||||
|
||||
Same return shape as ``_read_local_exif``, for providers (Nextcloud)
|
||||
whose files live on a remote server rather than the local filesystem -
|
||||
the caller downloads the file once for enrichment, regardless of which
|
||||
quality is used for display. ``mtime_fallback_ms`` takes the place of
|
||||
the filesystem mtime fallback (e.g. the WebDAV ``Last-Modified`` date).
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
if isinstance(mtime_fallback_ms, int):
|
||||
out["captured_at"] = mtime_fallback_ms
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except Exception: # pragma: no cover - Pillow ships with HA core
|
||||
return out
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
with Image.open(io.BytesIO(data)) as img:
|
||||
_read_exif_from_image(img, out)
|
||||
except Exception as err:
|
||||
_LOGGER.debug("EXIF: failed to read image bytes: %s", err)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _format_nominatim_location(payload: dict[str, Any]) -> str | None:
|
||||
"""Turn a Nominatim reverse-geocode response into a short label.
|
||||
|
||||
@@ -939,6 +1007,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_photoprism()
|
||||
elif self.provider == PROVIDER_ICLOUD:
|
||||
data = await self._update_icloud()
|
||||
elif self.provider == PROVIDER_SYNOLOGY:
|
||||
data = await self._update_synology()
|
||||
elif self.provider == PROVIDER_NEXTCLOUD:
|
||||
data = await self._update_nextcloud()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -952,7 +1024,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
raise
|
||||
|
||||
items = data.get("items") or []
|
||||
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) and items:
|
||||
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH, PROVIDER_NEXTCLOUD) and items:
|
||||
# Carry forward EXIF/geocode metadata for items we've already
|
||||
# scanned this session; new items get filled in by the
|
||||
# background worker below.
|
||||
@@ -1454,18 +1526,23 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
async def _update_icloud(self) -> dict[str, Any]:
|
||||
"""Fetch photos from a public iCloud Shared Album.
|
||||
|
||||
The webstream response carries capture date and caption inline, so
|
||||
there is no enrichment pass. Signed image URLs are resolved up front
|
||||
and expire after roughly a day, so they are refreshed on every album
|
||||
refresh (like Google Photos).
|
||||
Two public backends are supported: the legacy "shared streams" API and
|
||||
the newer CloudKit backend used by iOS 26/macOS 26 share links. Both
|
||||
carry capture date and caption inline, so there is no enrichment pass.
|
||||
Signed image URLs are resolved up front and expire after a while, so
|
||||
they are refreshed on every album refresh (like Google Photos).
|
||||
"""
|
||||
from . import icloud as icloud_api
|
||||
|
||||
token = self.entry.data.get(CONF_ICLOUD_TOKEN)
|
||||
size = self.entry.data.get(CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE)
|
||||
backend = self.entry.data.get(CONF_ICLOUD_BACKEND, DEFAULT_ICLOUD_BACKEND)
|
||||
if not token:
|
||||
raise UpdateFailed("iCloud provider is missing the album token")
|
||||
|
||||
if backend == ICLOUD_BACKEND_CLOUDKIT:
|
||||
return await self._update_icloud_cloudkit(icloud_api, token, size)
|
||||
|
||||
client = icloud_api.IcloudClient(self.hass, token)
|
||||
try:
|
||||
photos = await client.async_get_photos()
|
||||
@@ -1512,6 +1589,289 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_icloud_cloudkit(
|
||||
self, icloud_api, token: str, size: str
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch photos from a CloudKit-backed iCloud shared album."""
|
||||
client = icloud_api.IcloudCloudKitClient(self.hass, token)
|
||||
try:
|
||||
photos = await client.async_get_items(size)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error querying iCloud album: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed("No images found in the iCloud album")
|
||||
|
||||
items: list[MediaItem] = [
|
||||
MediaItem(
|
||||
url=p["url"],
|
||||
width=p.get("width"),
|
||||
height=p.get("height"),
|
||||
mime_type=None,
|
||||
filename=None,
|
||||
captured_at=p.get("captured_at"),
|
||||
description=p.get("description"),
|
||||
source_id=p.get("source_id"),
|
||||
exif_scanned=True,
|
||||
)
|
||||
for p in photos
|
||||
if p.get("url")
|
||||
]
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("Could not resolve any iCloud image URLs")
|
||||
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_synology(self) -> dict[str, Any]:
|
||||
"""Fetch photos from a Synology Photos library via its web API.
|
||||
|
||||
Metadata (capture date, GPS, address, description) is returned inline
|
||||
with each item, so every ``MediaItem`` is built fully here - there is
|
||||
no background enrichment pass. Thumbnail URLs carry no SID; the session
|
||||
cookie is stored on the coordinator and sent server-side by the camera
|
||||
(like the Immich x-api-key), so the SID never reaches the browser.
|
||||
"""
|
||||
from . import synology as syn_api
|
||||
|
||||
url = self.entry.data.get(CONF_SYNOLOGY_URL)
|
||||
username = self.entry.data.get(CONF_SYNOLOGY_USERNAME)
|
||||
password = self.entry.data.get(CONF_SYNOLOGY_PASSWORD)
|
||||
device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
|
||||
space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
|
||||
album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
|
||||
passphrase = self.entry.data.get(CONF_SYNOLOGY_PASSPHRASE)
|
||||
favorite_only = bool(self.entry.data.get(CONF_SYNOLOGY_FAVORITE))
|
||||
selection_raw = self.entry.data.get(CONF_SYNOLOGY_SELECTION)
|
||||
size = self.entry.data.get(
|
||||
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||
)
|
||||
if not url or not username or not password:
|
||||
raise UpdateFailed("Synology provider is missing URL or credentials")
|
||||
|
||||
client = syn_api.SynologyClient(
|
||||
self.hass,
|
||||
url,
|
||||
username=username,
|
||||
password=password,
|
||||
device_id=device_id,
|
||||
space=space,
|
||||
)
|
||||
try:
|
||||
await client.async_login()
|
||||
if selection_raw:
|
||||
# Composite selection (albums + people + places + tags +
|
||||
# subjects + favorites), merged client-side.
|
||||
try:
|
||||
selection = json.loads(selection_raw)
|
||||
except (TypeError, ValueError):
|
||||
selection = {}
|
||||
photos = await client.async_collect_composite(selection)
|
||||
else:
|
||||
# Legacy single-source entries (favorites / one album / all).
|
||||
photos = await client.async_collect_assets(
|
||||
album_id or None,
|
||||
passphrase=passphrase or None,
|
||||
favorite_only=favorite_only,
|
||||
)
|
||||
except syn_api.SynologyPermissionError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
|
||||
|
||||
if not photos:
|
||||
await client.async_logout()
|
||||
raise UpdateFailed("No images found for the selected Synology source")
|
||||
|
||||
# Store the session cookie so the camera can fetch thumbnail bytes
|
||||
# server-side. Do not log out: the SID must stay valid until the next
|
||||
# refresh re-authenticates.
|
||||
self.image_request_headers = dict(client.image_headers)
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
ref = syn_api.thumbnail_ref(p)
|
||||
if not ref:
|
||||
continue
|
||||
unit_id, cache_key = ref
|
||||
meta = syn_api.parse_photo_meta(p)
|
||||
# Items pulled from a shared-with-me album carry their own
|
||||
# passphrase (composite path); fall back to the single-album
|
||||
# passphrase for legacy entries.
|
||||
item_pp = p.get("_passphrase") or passphrase or None
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=syn_api.build_thumbnail_url(
|
||||
client.base_url,
|
||||
unit_id,
|
||||
cache_key,
|
||||
size,
|
||||
space,
|
||||
passphrase=item_pp,
|
||||
),
|
||||
width=meta.get("width"),
|
||||
height=meta.get("height"),
|
||||
mime_type=None,
|
||||
filename=p.get("filename"),
|
||||
captured_at=meta.get("captured_at"),
|
||||
byte_size=meta.get("byte_size"),
|
||||
latitude=meta.get("latitude"),
|
||||
longitude=meta.get("longitude"),
|
||||
location=meta.get("location"),
|
||||
description=meta.get("description"),
|
||||
source_id=str(p.get("id")) if p.get("id") is not None else None,
|
||||
exif_scanned=True,
|
||||
)
|
||||
)
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("Could not resolve any Synology images")
|
||||
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_nextcloud(self) -> dict[str, Any]:
|
||||
"""List photos from an authenticated Nextcloud WebDAV folder.
|
||||
|
||||
The PROPFIND listing carries filename/size/content-type/mtime but no
|
||||
EXIF, so capture date, GPS and description are filled in afterwards by
|
||||
the background enrichment worker (one original-file download per photo -
|
||||
Nextcloud has no metadata-only endpoint the way Immich does). The app
|
||||
password is sent server-side only via the coordinator's image headers.
|
||||
"""
|
||||
from . import nextcloud as nc_api
|
||||
|
||||
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
|
||||
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
|
||||
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
|
||||
recursive = bool(self.entry.data.get(CONF_NEXTCLOUD_RECURSIVE, False))
|
||||
size = self.entry.data.get(
|
||||
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||
)
|
||||
if not url or not username or not password:
|
||||
raise UpdateFailed("Nextcloud provider is missing URL or credentials")
|
||||
|
||||
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
|
||||
try:
|
||||
photos = await client.async_list_photos(recursive=recursive)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Error listing Nextcloud folder: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed("No images found in the Nextcloud folder")
|
||||
|
||||
# The camera fetches image bytes server-side with this Basic-auth
|
||||
# header, so the app password never appears in the browser URL.
|
||||
self.image_request_headers = dict(client.image_headers)
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
href = p.get("href")
|
||||
if not href:
|
||||
continue
|
||||
if size != NEXTCLOUD_IMAGE_ORIGINAL and p.get("file_id"):
|
||||
display_url = nc_api.build_preview_url(
|
||||
client.base_url, p["file_id"], NEXTCLOUD_PREVIEW_PX
|
||||
)
|
||||
else:
|
||||
display_url = href
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=display_url,
|
||||
width=None,
|
||||
height=None,
|
||||
mime_type=p.get("content_type"),
|
||||
filename=p.get("filename"),
|
||||
uploaded_at=p.get("mtime_ms"),
|
||||
byte_size=p.get("size"),
|
||||
source_id=p.get("file_id") or href,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"title": self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _enrich_nextcloud_item(self, item: MediaItem) -> None:
|
||||
"""Download one Nextcloud photo's original bytes and read its EXIF.
|
||||
|
||||
Nextcloud's WebDAV folder has no metadata-only endpoint (unlike
|
||||
Immich's per-asset detail call), so enrichment costs one full-file
|
||||
download per photo regardless of the display quality configured.
|
||||
"""
|
||||
from . import nextcloud as nc_api
|
||||
from urllib.parse import quote
|
||||
|
||||
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
|
||||
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
|
||||
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
|
||||
if not username or not password or not url:
|
||||
item.exif_scanned = True
|
||||
return
|
||||
|
||||
# Reconstruct the original-file URL: for preview items the display url
|
||||
# is the preview endpoint, so fall back to the folder href by filename.
|
||||
original_url = None
|
||||
if isinstance(item.url, str) and "/remote.php/dav/files/" in item.url:
|
||||
original_url = item.url
|
||||
elif item.filename:
|
||||
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
|
||||
original_url = client.dav_root + quote(item.filename)
|
||||
if not original_url:
|
||||
item.exif_scanned = True
|
||||
return
|
||||
|
||||
headers = {
|
||||
"Authorization": nc_api.basic_auth_header(username, password)
|
||||
}
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
async with session.get(original_url, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||
total += len(chunk)
|
||||
if total > _NEXTCLOUD_ENRICH_MAX_BYTES:
|
||||
_LOGGER.debug(
|
||||
"Nextcloud: %s exceeded %d byte enrichment cap; skipping",
|
||||
item.filename, _NEXTCLOUD_ENRICH_MAX_BYTES,
|
||||
)
|
||||
item.exif_scanned = True
|
||||
return
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Nextcloud: failed to download %s for enrichment: %s",
|
||||
item.filename, err,
|
||||
)
|
||||
item.exif_scanned = True
|
||||
return
|
||||
|
||||
info = await self.hass.async_add_executor_job(
|
||||
_read_exif_from_bytes, data, item.uploaded_at
|
||||
)
|
||||
if "captured_at" in info:
|
||||
item.captured_at = info["captured_at"]
|
||||
if "description" in info:
|
||||
item.description = info["description"]
|
||||
if "latitude" in info and "longitude" in info:
|
||||
item.latitude = info["latitude"]
|
||||
item.longitude = info["longitude"]
|
||||
item.exif_scanned = True
|
||||
|
||||
async def _enrich_immich_item(self, item: MediaItem) -> None:
|
||||
"""Fetch one Immich asset's detail and fill location/description."""
|
||||
from . import immich as immich_api
|
||||
@@ -1584,6 +1944,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
self.async_set_updated_data(data)
|
||||
continue
|
||||
|
||||
if self.provider == PROVIDER_NEXTCLOUD:
|
||||
try:
|
||||
await self._enrich_nextcloud_item(item)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("Nextcloud enrich error: %s", err)
|
||||
item.exif_scanned = True
|
||||
scanned_since_save += 1
|
||||
self._enrich_progress["exif_done"] = (
|
||||
self._enrich_progress.get("exif_done", 0) + 1
|
||||
)
|
||||
if scanned_since_save >= _EXIF_BATCH_SAVE:
|
||||
scanned_since_save = 0
|
||||
await self._save_cached_items(data)
|
||||
self.async_set_updated_data(data)
|
||||
continue
|
||||
|
||||
url = item.url
|
||||
if not url.startswith("file://"):
|
||||
item.exif_scanned = True
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""iCloud Shared Album client and pure parsing helpers.
|
||||
|
||||
Talks to Apple's public "shared streams" web API for a shared photo album -
|
||||
the same undocumented JSON endpoints the iCloud web album viewer uses. No
|
||||
account or password is involved; the album's share token (the part after
|
||||
``#`` in the share link) is the only credential.
|
||||
Reads a public iCloud shared photo album. No account or password is involved;
|
||||
the album's share token (from the share link) is the only credential. Apple
|
||||
serves shared albums through two different public backends depending on when
|
||||
the album was created, and this module speaks both:
|
||||
|
||||
API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com``):
|
||||
Legacy "shared streams" backend (``www.icloud.com/sharedalbum/#TOKEN``):
|
||||
- ``POST {base}/webstream`` ``{"streamCtag": null}`` -> ``{streamName, photos:
|
||||
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
|
||||
dateCreated, caption, width, height}]}``. May first answer with a
|
||||
@@ -16,13 +16,24 @@ API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com`
|
||||
as ``https://{url_location}{url_path}``; it is a signed CDN link that
|
||||
expires after roughly a day, so it is refreshed on every album refresh.
|
||||
|
||||
Metadata: capture date (``dateCreated``) and caption are inline. Apple strips
|
||||
GPS from shared-album web data, so there is no location.
|
||||
CloudKit backend (``photos.icloud.com/shared/album/TOKEN``, iOS 26/macOS 26+):
|
||||
- ``POST ckdatabasews.icloud.com/.../public/records/resolve?shortGUID=TOKEN``
|
||||
resolves the short link to a shared CloudKit zone and hands back an
|
||||
anonymous ``publicAccessAuthToken`` plus the partition host to talk to.
|
||||
- ``POST {partition}/.../shared/records/query`` with the anonymous token and
|
||||
``sharing_url_key`` returns ``CPLMaster``/``CPLAsset`` records; each
|
||||
``CPLMaster`` carries signed ``downloadURL`` derivatives directly.
|
||||
|
||||
Both backends are POST with ``Content-Type: text/plain`` and
|
||||
``Origin: https://www.icloud.com``. Metadata (capture date, caption) is inline;
|
||||
Apple strips GPS from shared-album web data, so there is no location.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import async_timeout
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -34,6 +45,16 @@ _MAX_ASSETS = 20_000
|
||||
_URL_BATCH = 25
|
||||
|
||||
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
# Characters allowed in a share token. Legacy shared-streams tokens are base62;
|
||||
# the newer CloudKit short GUIDs use a URL-safe base64 alphabet, so they may
|
||||
# also contain ``-`` and ``_`` (e.g. ``045YeI20-8u3X31bBPD5z9B_A``).
|
||||
_TOKEN_CHARS = frozenset(_BASE62 + "-_")
|
||||
|
||||
# Which iCloud backend serves an album. These string values match the
|
||||
# ``ICLOUD_BACKEND_*`` constants in ``const.py``; they are duplicated here to
|
||||
# keep this module free of a hard dependency on ``const``.
|
||||
BACKEND_SHAREDSTREAMS = "sharedstreams"
|
||||
BACKEND_CLOUDKIT = "cloudkit"
|
||||
|
||||
# Headers Apple's web endpoints expect for the shared-streams API.
|
||||
_API_HEADERS = {
|
||||
@@ -42,6 +63,37 @@ _API_HEADERS = {
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# --- CloudKit backend constants -------------------------------------------
|
||||
_CK_RESOLVE_HOST = "https://ckdatabasews.icloud.com"
|
||||
_CK_CONTAINER = "com.apple.photos.cloud"
|
||||
_CK_BUILD = "2626"
|
||||
# Sorted, non-hidden, non-deleted assets. Returns both CPLMaster (image
|
||||
# resources) and CPLAsset (metadata) records in one query.
|
||||
_CK_RECORD_TYPE = "CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted"
|
||||
_CK_PAGE = 200
|
||||
|
||||
_CK_HEADERS = {
|
||||
"Content-Type": "text/plain",
|
||||
"Origin": "https://www.icloud.com",
|
||||
"Referer": "https://www.icloud.com/",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# Image resource fields on a CPLMaster, in ascending size order, paired with
|
||||
# their width/height sibling fields.
|
||||
_CK_IMAGE_RES = (
|
||||
("resJPEGThumbRes", "resJPEGThumbWidth", "resJPEGThumbHeight"),
|
||||
("resJPEGMedRes", "resJPEGMedWidth", "resJPEGMedHeight"),
|
||||
("resJPEGLargeRes", "resJPEGLargeWidth", "resJPEGLargeHeight"),
|
||||
("resOriginalRes", "resOriginalWidth", "resOriginalHeight"),
|
||||
)
|
||||
# Original item types a browser can display directly. The JPEG derivatives are
|
||||
# always browser-safe; the original is only used as a fallback when it is one
|
||||
# of these.
|
||||
_CK_BROWSER_SAFE_ORIGINAL = {"public.jpeg", "public.png"}
|
||||
# itemType substrings that mark a master as a video (skipped in a slideshow).
|
||||
_CK_VIDEO_HINTS = ("movie", "video", "mpeg-4", "quicktime")
|
||||
|
||||
|
||||
def _base62_to_int(value: str) -> int:
|
||||
result = 0
|
||||
@@ -53,23 +105,52 @@ def _base62_to_int(value: str) -> int:
|
||||
def parse_share_link(url: str) -> str | None:
|
||||
"""Extract the album share token from a pasted iCloud link.
|
||||
|
||||
Accepts a full ``https://www.icloud.com/sharedalbum/#TOKEN`` link or a
|
||||
bare token. Returns ``None`` if nothing token-like is found.
|
||||
Accepts a legacy ``https://www.icloud.com/sharedalbum/#TOKEN`` link, a new
|
||||
``https://photos.icloud.com/shared/album/TOKEN`` link, or a bare token.
|
||||
Returns ``None`` if nothing token-like is found.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
text = url.strip()
|
||||
# Legacy links carry the token in the fragment; new links carry it as the
|
||||
# last path segment.
|
||||
if "#" in text:
|
||||
text = text.rsplit("#", 1)[1]
|
||||
elif "/" in text:
|
||||
text = text.rstrip("/").rsplit("/", 1)[1]
|
||||
# Tokens are base62 and start with an uppercase letter (A, B, ...).
|
||||
# Drop any leftover query string.
|
||||
text = text.split("?", 1)[0]
|
||||
token = text.strip()
|
||||
if token and all(ch in _BASE62 for ch in token):
|
||||
# Legacy tokens are base62 and start with an uppercase letter; the newer
|
||||
# CloudKit short GUIDs may start with a digit and can contain ``-``/``_``,
|
||||
# so accept the wider URL-safe alphabet without a leading-char check.
|
||||
if token and all(ch in _TOKEN_CHARS for ch in token):
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def detect_backend(url: str) -> str:
|
||||
"""Return which iCloud backend a pasted share link belongs to.
|
||||
|
||||
New ``photos.icloud.com/shared/album/...`` links use the CloudKit backend;
|
||||
everything else (including bare tokens) defaults to the legacy shared
|
||||
streams backend, preserving behaviour for links created before CloudKit.
|
||||
"""
|
||||
text = (url or "").lower()
|
||||
if "/shared/album/" in text or "photos.icloud.com" in text:
|
||||
return BACKEND_CLOUDKIT
|
||||
return BACKEND_SHAREDSTREAMS
|
||||
|
||||
|
||||
def parse_share(url: str) -> tuple[str, str] | None:
|
||||
"""Parse a share link into ``(token, backend)`` or ``None`` if invalid."""
|
||||
token = parse_share_link(url)
|
||||
if not token:
|
||||
return None
|
||||
return token, detect_backend(url)
|
||||
|
||||
|
||||
|
||||
def partition_host(token: str) -> str:
|
||||
"""Derive the shared-streams partition host for a token.
|
||||
|
||||
@@ -250,3 +331,248 @@ class IcloudClient:
|
||||
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
return payload.get("streamName") if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
# --- CloudKit backend helpers ---------------------------------------------
|
||||
|
||||
|
||||
def _ck_value(fields: dict[str, Any], name: str) -> Any:
|
||||
"""Return the inner ``value`` of a CloudKit field, or ``None``."""
|
||||
field = fields.get(name) if isinstance(fields, dict) else None
|
||||
return field.get("value") if isinstance(field, dict) else None
|
||||
|
||||
|
||||
def _ck_decode_filename(fields: dict[str, Any]) -> str | None:
|
||||
"""Decode the (base64) ``filenameEnc`` field of a CPLMaster, if present."""
|
||||
raw = _ck_value(fields, "filenameEnc")
|
||||
if isinstance(raw, str) and raw:
|
||||
try:
|
||||
return base64.b64decode(raw).decode("utf-8", "replace")
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ck_is_video(master_fields: dict[str, Any]) -> bool:
|
||||
"""Return ``True`` when a CPLMaster describes a video (not a still image).
|
||||
|
||||
Only the ``itemType`` is used: Live Photos keep an image ``itemType`` (and
|
||||
carry a non-zero asset duration), so they are treated as stills and shown.
|
||||
"""
|
||||
item_type = _ck_value(master_fields, "itemType")
|
||||
if isinstance(item_type, str):
|
||||
lowered = item_type.lower()
|
||||
return any(hint in lowered for hint in _CK_VIDEO_HINTS)
|
||||
return False
|
||||
|
||||
|
||||
def pick_ck_resource(
|
||||
master_fields: dict[str, Any], size: str
|
||||
) -> tuple[str, Any, Any] | None:
|
||||
"""Pick a downloadable image derivative for the requested display ``size``.
|
||||
|
||||
Returns ``(download_url, width, height)``. ``full`` selects the largest
|
||||
available derivative (best for a slideshow); ``preview`` selects the
|
||||
smallest. JPEG derivatives are always browser-safe; the original is only
|
||||
considered when it is itself a browser-displayable format.
|
||||
"""
|
||||
if not isinstance(master_fields, dict):
|
||||
return None
|
||||
item_type = _ck_value(master_fields, "itemType")
|
||||
item_type = item_type.lower() if isinstance(item_type, str) else ""
|
||||
original_safe = item_type in _CK_BROWSER_SAFE_ORIGINAL
|
||||
|
||||
candidates: list[tuple[int, str, Any, Any]] = []
|
||||
for res_key, w_key, h_key in _CK_IMAGE_RES:
|
||||
res = master_fields.get(res_key)
|
||||
if not isinstance(res, dict):
|
||||
continue
|
||||
val = res.get("value")
|
||||
if not isinstance(val, dict):
|
||||
continue
|
||||
download_url = val.get("downloadURL")
|
||||
if not download_url:
|
||||
continue
|
||||
if res_key == "resOriginalRes" and not original_safe:
|
||||
continue
|
||||
width = _ck_value(master_fields, w_key)
|
||||
height = _ck_value(master_fields, h_key)
|
||||
try:
|
||||
rank = int(width)
|
||||
except (TypeError, ValueError):
|
||||
rank = 0
|
||||
candidates.append((rank, download_url, width, height))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda c: c[0])
|
||||
_rank, download_url, width, height = candidates[0 if size == "preview" else -1]
|
||||
return download_url, width, height
|
||||
|
||||
|
||||
def build_ck_image_url(download_url: str | None, filename: str | None = None) -> str | None:
|
||||
"""Fill the ``${f}`` filename placeholder in a CloudKit download URL."""
|
||||
if not download_url:
|
||||
return None
|
||||
return download_url.replace("${f}", quote(filename or "image", safe=""))
|
||||
|
||||
|
||||
def _ck_share_title(resolve_result: dict[str, Any]) -> str | None:
|
||||
"""Return the album title from a resolve result's share record, if any."""
|
||||
share = resolve_result.get("share") if isinstance(resolve_result, dict) else None
|
||||
fields = share.get("fields") if isinstance(share, dict) else None
|
||||
title = _ck_value(fields, "cloudkit.title") if isinstance(fields, dict) else None
|
||||
if isinstance(title, str) and title.strip():
|
||||
return title.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int | None:
|
||||
return int(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def parse_ck_records(records: list[Any], size: str) -> list[dict[str, Any]]:
|
||||
"""Join CPLMaster + CPLAsset records into normalized photo dicts.
|
||||
|
||||
Each returned dict has ``source_id``, ``url``, ``width``, ``height``,
|
||||
``captured_at`` (epoch ms) and ``description``.
|
||||
"""
|
||||
masters: dict[str, dict[str, Any]] = {}
|
||||
assets: list[dict[str, Any]] = []
|
||||
for rec in records:
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
if rec.get("recordType") == "CPLMaster":
|
||||
masters[rec.get("recordName")] = rec.get("fields") or {}
|
||||
elif rec.get("recordType") == "CPLAsset":
|
||||
assets.append(rec)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for asset in assets:
|
||||
af = asset.get("fields") or {}
|
||||
if _ck_value(af, "isHidden") or _ck_value(af, "trashReason"):
|
||||
continue
|
||||
ref = af.get("masterRef")
|
||||
ref_val = ref.get("value") if isinstance(ref, dict) else None
|
||||
master_name = ref_val.get("recordName") if isinstance(ref_val, dict) else None
|
||||
master_fields = masters.get(master_name)
|
||||
if not master_fields or _ck_is_video(master_fields):
|
||||
continue
|
||||
picked = pick_ck_resource(master_fields, size)
|
||||
if not picked:
|
||||
continue
|
||||
download_url, width, height = picked
|
||||
url = build_ck_image_url(download_url, _ck_decode_filename(master_fields))
|
||||
if not url:
|
||||
continue
|
||||
source_id = master_name or asset.get("recordName")
|
||||
if not source_id or source_id in seen:
|
||||
continue
|
||||
seen.add(source_id)
|
||||
out.append(
|
||||
{
|
||||
"source_id": source_id,
|
||||
"url": url,
|
||||
"width": _to_int(width),
|
||||
"height": _to_int(height),
|
||||
"captured_at": _to_int(_ck_value(af, "assetDate")),
|
||||
"description": None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class IcloudCloudKitClient:
|
||||
"""Async client for the CloudKit-backed iCloud shared album backend.
|
||||
|
||||
Everything is anonymous: a ``resolve`` call turns the short share token
|
||||
into a shared CloudKit zone plus a short-lived anonymous access token, and
|
||||
a ``records/query`` call returns the album's photos with signed image URLs
|
||||
already embedded.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, token: str) -> None:
|
||||
self.hass = hass
|
||||
self.token = token
|
||||
# Cached (zone, base_url, access_token, title) from the resolve call.
|
||||
self._resolved: tuple[dict[str, Any], str, str, str | None] | None = None
|
||||
|
||||
async def _post(self, url: str, body: dict[str, Any]) -> tuple[int, bytes]:
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with async_timeout.timeout(_TIMEOUT):
|
||||
async with session.post(url, json=body, headers=_CK_HEADERS) as resp:
|
||||
return resp.status, await resp.read()
|
||||
|
||||
async def _resolve(self) -> tuple[dict[str, Any], str, str, str | None]:
|
||||
if self._resolved is not None:
|
||||
return self._resolved
|
||||
import json as _json
|
||||
|
||||
url = (
|
||||
f"{_CK_RESOLVE_HOST}/database/1/{_CK_CONTAINER}/production"
|
||||
f"/public/records/resolve?ckjsBuildVersion={_CK_BUILD}"
|
||||
f"&shortGUID={self.token}"
|
||||
)
|
||||
status, raw = await self._post(url, {"shortGUIDs": [{"value": self.token}]})
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud resolve failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
results = payload.get("results") if isinstance(payload, dict) else None
|
||||
if not results:
|
||||
raise RuntimeError("iCloud share link did not resolve to an album")
|
||||
result = results[0]
|
||||
zone = result.get("zoneID")
|
||||
access = result.get("anonymousPublicAccess") or {}
|
||||
access_token = access.get("token")
|
||||
partition = access.get("databasePartition")
|
||||
if not zone or not access_token or not partition:
|
||||
raise RuntimeError("iCloud shared album is not publicly accessible")
|
||||
base = f"{partition}/database/1/{_CK_CONTAINER}/production"
|
||||
self._resolved = (zone, base, access_token, _ck_share_title(result))
|
||||
return self._resolved
|
||||
|
||||
def _query_url(self, base: str, access_token: str) -> str:
|
||||
query = urlencode(
|
||||
{
|
||||
"remapEnums": "true",
|
||||
"getCurrentSyncToken": "true",
|
||||
"sharing_url_key": self.token,
|
||||
"publicAccessAuthToken": access_token,
|
||||
}
|
||||
)
|
||||
return f"{base}/shared/records/query?{query}"
|
||||
|
||||
async def async_validate(self) -> str | None:
|
||||
"""Return the album title if the share link resolves, else raise."""
|
||||
_zone, _base, _token, title = await self._resolve()
|
||||
return title
|
||||
|
||||
async def async_get_items(self, size: str) -> list[dict[str, Any]]:
|
||||
"""Return normalized photo dicts for the album (see ``parse_ck_records``)."""
|
||||
import json as _json
|
||||
|
||||
zone, base, access_token, _title = await self._resolve()
|
||||
url = self._query_url(base, access_token)
|
||||
records: list[Any] = []
|
||||
continuation: str | None = None
|
||||
while len(records) < _MAX_ASSETS:
|
||||
body: dict[str, Any] = {
|
||||
"zoneID": zone,
|
||||
"query": {"recordType": _CK_RECORD_TYPE},
|
||||
"resultsLimit": _CK_PAGE,
|
||||
}
|
||||
if continuation:
|
||||
body["continuationMarker"] = continuation
|
||||
status, raw = await self._post(url, body)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"iCloud records query failed: HTTP {status}")
|
||||
payload = _json.loads(raw)
|
||||
batch = payload.get("records") if isinstance(payload, dict) else None
|
||||
if not batch:
|
||||
break
|
||||
records.extend(batch)
|
||||
continuation = payload.get("continuationMarker") if isinstance(payload, dict) else None
|
||||
if not continuation:
|
||||
break
|
||||
return parse_ck_records(records, size)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["Pillow"],
|
||||
"version": "1.4.0"
|
||||
"version": "1.7.1"
|
||||
}
|
||||
|
||||
@@ -78,12 +78,50 @@
|
||||
},
|
||||
"icloud": {
|
||||
"title": "iCloud Shared Album",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). No Apple ID or password is needed - the link itself is the credential. Capture date and captions come through; Apple does not include location in shared albums.",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). Both the older icloud.com/sharedalbum links and the newer photos.icloud.com/shared/album links work. No Apple ID or password is needed - the link itself is the credential. Capture date comes through; Apple does not include location in shared albums.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"icloud_url": "Shared album link",
|
||||
"icloud_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"synology": {
|
||||
"title": "Synology Photos",
|
||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Choose Personal for your own photos and albums (this is also where albums shared with you appear); choose Shared Space only if your NAS has the shared team library enabled. Tip: use a dedicated Photos account rather than an admin login.",
|
||||
"data": {
|
||||
"synology_url": "DSM URL",
|
||||
"synology_username": "Username",
|
||||
"synology_password": "Password",
|
||||
"synology_space": "Library",
|
||||
"otp_code": "2FA code (only if enabled)"
|
||||
}
|
||||
},
|
||||
"synology_select": {
|
||||
"title": "Synology source",
|
||||
"description": "Choose what to show. Tick any mix of favorites, albums, people, places, tags and subjects; they are combined into one slideshow. Leave everything empty to show all photos in this space.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"favorites": "Favorites",
|
||||
"albums": "Albums",
|
||||
"people": "People",
|
||||
"places": "Places",
|
||||
"tags": "Tags",
|
||||
"subjects": "Subjects",
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud folder",
|
||||
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"nextcloud_url": "Server URL",
|
||||
"nextcloud_username": "Username",
|
||||
"nextcloud_password": "App password",
|
||||
"nextcloud_folder": "Folder path (optional)",
|
||||
"nextcloud_recursive": "Include subfolders",
|
||||
"nextcloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -100,13 +138,17 @@
|
||||
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.",
|
||||
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Folder options",
|
||||
"title": "Location & privacy options",
|
||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||
"data": {
|
||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||
|
||||
@@ -78,12 +78,50 @@
|
||||
},
|
||||
"icloud": {
|
||||
"title": "iCloud Shared Album",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). No Apple ID or password is needed - the link itself is the credential. Capture date and captions come through; Apple does not include location in shared albums.",
|
||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). Both the older icloud.com/sharedalbum links and the newer photos.icloud.com/shared/album links work. No Apple ID or password is needed - the link itself is the credential. Capture date comes through; Apple does not include location in shared albums.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"icloud_url": "Shared album link",
|
||||
"icloud_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"synology": {
|
||||
"title": "Synology Photos",
|
||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Choose Personal for your own photos and albums (this is also where albums shared with you appear); choose Shared Space only if your NAS has the shared team library enabled. Tip: use a dedicated Photos account rather than an admin login.",
|
||||
"data": {
|
||||
"synology_url": "DSM URL",
|
||||
"synology_username": "Username",
|
||||
"synology_password": "Password",
|
||||
"synology_space": "Library",
|
||||
"otp_code": "2FA code (only if enabled)"
|
||||
}
|
||||
},
|
||||
"synology_select": {
|
||||
"title": "Synology source",
|
||||
"description": "Choose what to show. Tick any mix of favorites, albums, people, places, tags and subjects; they are combined into one slideshow. Leave everything empty to show all photos in this space.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"favorites": "Favorites",
|
||||
"albums": "Albums",
|
||||
"people": "People",
|
||||
"places": "Places",
|
||||
"tags": "Tags",
|
||||
"subjects": "Subjects",
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud folder",
|
||||
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
|
||||
"data": {
|
||||
"album_name": "Album name",
|
||||
"nextcloud_url": "Server URL",
|
||||
"nextcloud_username": "Username",
|
||||
"nextcloud_password": "App password",
|
||||
"nextcloud_folder": "Folder path (optional)",
|
||||
"nextcloud_recursive": "Include subfolders",
|
||||
"nextcloud_image_size": "Image quality"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -100,13 +138,17 @@
|
||||
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
|
||||
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
|
||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
|
||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.",
|
||||
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
|
||||
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Folder options",
|
||||
"title": "Location & privacy options",
|
||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||
"data": {
|
||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.4.0";
|
||||
const VERSION = "1.7.1";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user