Compare commits
1551
Commits
16fa7657f6
..
main
+1
-1
@@ -1 +1 @@
|
|||||||
2026.7.3
|
2026.8.1
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
!**/.??*.yaml
|
!**/.??*.yaml
|
||||||
!*.yml
|
!*.yml
|
||||||
!**/.??*.yml
|
!**/.??*.yml
|
||||||
|
!*.md
|
||||||
|
!**/.??*.md
|
||||||
|
!*.json
|
||||||
|
!**/.??*.json
|
||||||
|
!*.js
|
||||||
|
!**/.??*.js
|
||||||
|
|
||||||
# Track specific .storage configuration files
|
# Track specific .storage configuration files
|
||||||
!.storage/lovelace
|
!.storage/lovelace
|
||||||
|
|||||||
@@ -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.3", "start_ts": 1784912859.3136568}
|
{"pid": 70, "version": 1, "ha_version": "2026.8.1", "start_ts": 1786340131.7940845}
|
||||||
@@ -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
|
||||||
@@ -90,6 +90,16 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "entity"
|
"type": "entity"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.waze_time_waze_from_parents",
|
||||||
|
"name": "From Parents",
|
||||||
|
"color": "red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.waze_time_waze_from_work",
|
||||||
|
"name": "From Work",
|
||||||
|
"color": "green"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hours_to_show": 48,
|
"hours_to_show": 48,
|
||||||
@@ -181,7 +191,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "tile",
|
"type": "tile",
|
||||||
"entity": "input_boolean.meeting_lamp_override"
|
"entity": "input_boolean.meeting_lamp_override",
|
||||||
|
"name": "Meeting L-Override",
|
||||||
|
"vertical": false,
|
||||||
|
"features_position": "bottom"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "heading",
|
"type": "heading",
|
||||||
@@ -388,6 +401,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"features_position": "bottom"
|
"features_position": "bottom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "light.bedroom_lamp",
|
||||||
|
"show_entity_picture": false,
|
||||||
|
"hide_state": false,
|
||||||
|
"vertical": false,
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "light-brightness"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"features_position": "bottom"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -717,6 +743,57 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Power Usage",
|
||||||
|
"heading_style": "title"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:power-gauge-card",
|
||||||
|
"entity": "sensor.meter00_power",
|
||||||
|
"max": 7500,
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full",
|
||||||
|
"rows": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:power-gauge-bar-card",
|
||||||
|
"entities": [
|
||||||
|
"sensor.meter00_power"
|
||||||
|
],
|
||||||
|
"max": 7500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:power-gauge-bar-card",
|
||||||
|
"entities": [
|
||||||
|
"sensor.pool_pump_estimated_power_draw_watts",
|
||||||
|
"sensor.hvac_real_time_power_draw_watts",
|
||||||
|
"sensor.basement_heater_power_draw_watts",
|
||||||
|
"sensor.water_heater_power_draw_watts",
|
||||||
|
"sensor.washer_power",
|
||||||
|
"sensor.freezer_power",
|
||||||
|
"sensor.fridge_power",
|
||||||
|
"sensor.general_power",
|
||||||
|
"sensor.computer_power",
|
||||||
|
"sensor.microwave_power",
|
||||||
|
"sensor.ninja_power",
|
||||||
|
"sensor.coffee_maker_power"
|
||||||
|
],
|
||||||
|
"unit": "W",
|
||||||
|
"max": 5000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"show_name": true,
|
||||||
|
"show_icon": true,
|
||||||
|
"type": "button",
|
||||||
|
"entity": "input_boolean.pool_pump_in_use"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "grid",
|
"type": "grid",
|
||||||
"cards": [
|
"cards": [
|
||||||
@@ -925,22 +1002,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"column_span": 2
|
"column_span": 2
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "grid",
|
|
||||||
"cards": [
|
|
||||||
{
|
|
||||||
"type": "heading",
|
|
||||||
"heading": "Pool",
|
|
||||||
"heading_style": "title"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"show_name": true,
|
|
||||||
"show_icon": true,
|
|
||||||
"type": "button",
|
|
||||||
"entity": "input_boolean.pool_pump_in_use"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"badges": [
|
"badges": [
|
||||||
|
|||||||
@@ -163,6 +163,200 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"type": "grid",
|
||||||
"cards": [
|
"cards": [
|
||||||
@@ -308,11 +502,8 @@
|
|||||||
"cards": [
|
"cards": [
|
||||||
{
|
{
|
||||||
"type": "heading",
|
"type": "heading",
|
||||||
"heading": "New section"
|
"heading": "Network Stats",
|
||||||
},
|
"heading_style": "title"
|
||||||
{
|
|
||||||
"type": "custom:album-slideshow-card",
|
|
||||||
"entity": "camera.album_slideshow_google_screensaver"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "entities",
|
"type": "entities",
|
||||||
@@ -343,48 +534,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "custom:mini-graph-card",
|
|
||||||
"title": "Network Traffic (24h)",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"entity": "sensor.gt_ax11000_wan_download_speed",
|
|
||||||
"name": "Download",
|
|
||||||
"color": "#2196F3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"entity": "sensor.gt_ax11000_wan_upload_speed",
|
|
||||||
"name": "Upload",
|
|
||||||
"color": "#4CAF50"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"hours_to_show": 6,
|
|
||||||
"points_per_hour": 4,
|
|
||||||
"line_width": 4,
|
|
||||||
"fill": "fade",
|
|
||||||
"show": {
|
|
||||||
"graph": "line",
|
|
||||||
"fill": true,
|
|
||||||
"state": false,
|
|
||||||
"name": false,
|
|
||||||
"extrema": false,
|
|
||||||
"average": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "picture-glance",
|
|
||||||
"title": "Backyard Shed",
|
|
||||||
"camera_image": "camera.backyard_shed",
|
|
||||||
"camera_view": "live",
|
|
||||||
"entities": [],
|
|
||||||
"tap_action": {
|
|
||||||
"action": "call-service",
|
|
||||||
"service": "camera.turn_on",
|
|
||||||
"target": {
|
|
||||||
"entity_id": "camera.backyard_shed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "statistics-graph",
|
"type": "statistics-graph",
|
||||||
"entities": [
|
"entities": [
|
||||||
@@ -405,7 +554,8 @@
|
|||||||
"cards": [
|
"cards": [
|
||||||
{
|
{
|
||||||
"type": "heading",
|
"type": "heading",
|
||||||
"heading": "New section"
|
"heading": "Power Usage",
|
||||||
|
"heading_style": "title"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "custom:treemap-card",
|
"type": "custom:treemap-card",
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "custom:button-card",
|
"type": "custom:button-card",
|
||||||
"entity": "sensor.waze_to_work_travel_time",
|
"entity": "sensor.waze_time_waze_to_work_travel_time",
|
||||||
"show_name": true,
|
"show_name": true,
|
||||||
"show_label": true,
|
"show_label": true,
|
||||||
"name": "Waze",
|
"name": "Waze",
|
||||||
@@ -482,7 +482,7 @@
|
|||||||
{
|
{
|
||||||
"type": "statistics-graph",
|
"type": "statistics-graph",
|
||||||
"entities": [
|
"entities": [
|
||||||
"sensor.waze_to_work_travel_time_waze_to_parents"
|
"sensor.waze_time_waze_to_parents"
|
||||||
],
|
],
|
||||||
"days_to_show": 1,
|
"days_to_show": 1,
|
||||||
"period": "hour",
|
"period": "hour",
|
||||||
@@ -568,6 +568,24 @@
|
|||||||
"camera_image": "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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "custom:button-card",
|
"type": "custom:button-card",
|
||||||
"entity": "sensor.waze_to_work_travel_time",
|
"entity": "sensor.waze_time_waze_to_work_travel_time",
|
||||||
"show_name": true,
|
"show_name": true,
|
||||||
"show_label": true,
|
"show_label": true,
|
||||||
"name": "Waze",
|
"name": "Waze",
|
||||||
|
|||||||
@@ -0,0 +1,808 @@
|
|||||||
|
{
|
||||||
|
"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": "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",
|
||||||
|
"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(0) %}{% set h = (s / 3600) | int %}{% set m = ((s % 3600) / 60) | int %}Remaining: {% if h %}{{ h }} hr {{ m }} min{% else %}{{ m }} min{% 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": "sensor",
|
||||||
|
"entity": "sensor.octoprint_print_progress",
|
||||||
|
"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",
|
||||||
|
"entity": "sensor.octoprint_printer_print_time_last_7_days",
|
||||||
|
"name": "Print Time Last 7 Days",
|
||||||
|
"icon": "mdi:clock-history",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,15 @@
|
|||||||
"require_admin": false,
|
"require_admin": false,
|
||||||
"mode": "storage",
|
"mode": "storage",
|
||||||
"url_path": "dashboard-music"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-39
@@ -6,7 +6,7 @@
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "cc55565add58413bbcadba9f9cf8caba",
|
"id": "cc55565add58413bbcadba9f9cf8caba",
|
||||||
"url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375511",
|
"url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375522",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "53837b51a371459b95ffc0989ce615fb",
|
"id": "53837b51a371459b95ffc0989ce615fb",
|
||||||
"url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749320",
|
"url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749340",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "0b28e2d01d0d4033b2f0a339cc7d86c1",
|
"id": "0b28e2d01d0d4033b2f0a339cc7d86c1",
|
||||||
"url": "/ha_washdata/ha-washdata-card.js?v=1781361396",
|
"url": "/ha_washdata/ha-washdata-card.js?v=1785159024",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,11 +69,6 @@
|
|||||||
"url": "/local/thermostat-pro-timeline.js?v=56675817588421",
|
"url": "/local/thermostat-pro-timeline.js?v=56675817588421",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "5447aa1e0b934b57b58b98751c3c3350",
|
|
||||||
"url": "/hacsfiles/homie-dashboard/homie-dashboard.js?hacstag=1221720415400",
|
|
||||||
"type": "module"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "9a535448bf5645889b43fbdf481bb225",
|
"id": "9a535448bf5645889b43fbdf481bb225",
|
||||||
"url": " /local/community/homie-dashboard/homie-dashboard.js",
|
"url": " /local/community/homie-dashboard/homie-dashboard.js",
|
||||||
@@ -86,122 +81,122 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
|
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
|
||||||
"url": "/taskmate/taskmate-attr-resolver.js?v=5.0.1",
|
"url": "/taskmate/taskmate-attr-resolver.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "0796c92931dd458e81dc9c2677248274",
|
"id": "0796c92931dd458e81dc9c2677248274",
|
||||||
"url": "/taskmate/taskmate-localize.js?v=5.0.1",
|
"url": "/taskmate/taskmate-localize.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d2e281920ac7409583eafff1f1db69bd",
|
"id": "d2e281920ac7409583eafff1f1db69bd",
|
||||||
"url": "/taskmate/taskmate-design.js?v=5.0.1",
|
"url": "/taskmate/taskmate-design.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "9eae9632f0734480be5edd784b030673",
|
"id": "9eae9632f0734480be5edd784b030673",
|
||||||
"url": "/taskmate/taskmate-badges-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-badges-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "5df49cd8a4eb420497b5df22cb31362c",
|
"id": "5df49cd8a4eb420497b5df22cb31362c",
|
||||||
"url": "/taskmate/taskmate-child-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-child-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
|
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
|
||||||
"url": "/taskmate/taskmate-rewards-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-rewards-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "787a96f9c30548708165a48457c99feb",
|
"id": "787a96f9c30548708165a48457c99feb",
|
||||||
"url": "/taskmate/taskmate-approvals-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-approvals-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "84031597de424ad68e7c2bc66d06b72e",
|
"id": "84031597de424ad68e7c2bc66d06b72e",
|
||||||
"url": "/taskmate/taskmate-points-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-points-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "02613225844746b699bfbfac9642eca2",
|
"id": "02613225844746b699bfbfac9642eca2",
|
||||||
"url": "/taskmate/taskmate-reorder-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-reorder-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "7a06a07e17ba4b31b712bc91b77360b4",
|
"id": "7a06a07e17ba4b31b712bc91b77360b4",
|
||||||
"url": "/taskmate/taskmate-overview-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-overview-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "08b42b53b33b4fa88db932455cf8d5aa",
|
"id": "08b42b53b33b4fa88db932455cf8d5aa",
|
||||||
"url": "/taskmate/taskmate-activity-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-activity-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "15907eff51784acdba88ff694b574509",
|
"id": "15907eff51784acdba88ff694b574509",
|
||||||
"url": "/taskmate/taskmate-streak-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-streak-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
|
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
|
||||||
"url": "/taskmate/taskmate-weekly-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-weekly-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "289ce5943f5a4972a593697db4c96ece",
|
"id": "289ce5943f5a4972a593697db4c96ece",
|
||||||
"url": "/taskmate/taskmate-graph-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-graph-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d2904acee4c74a398dcc7b996c23e7e7",
|
"id": "d2904acee4c74a398dcc7b996c23e7e7",
|
||||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "46d378736fd54a26be253f2dfb27202e",
|
"id": "46d378736fd54a26be253f2dfb27202e",
|
||||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "db11ae36457842fa826c7991e1d9c6f4",
|
"id": "db11ae36457842fa826c7991e1d9c6f4",
|
||||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
|
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
|
||||||
"url": "/taskmate/taskmate-penalties-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-penalties-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1959e1629de2422daa8adaf87069c288",
|
"id": "1959e1629de2422daa8adaf87069c288",
|
||||||
"url": "/taskmate/taskmate-bonuses-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-bonuses-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2548524b269c4959bc7e0e967df943af",
|
"id": "2548524b269c4959bc7e0e967df943af",
|
||||||
"url": "/taskmate/taskmate-points-display-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-points-display-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "36ada49368db4187a283fbbbef45454f",
|
"id": "36ada49368db4187a283fbbbef45454f",
|
||||||
"url": "/taskmate/taskmate-calendar-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-calendar-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "67c32687acd2497b90030ef141f4b6db",
|
"id": "67c32687acd2497b90030ef141f4b6db",
|
||||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
|
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
|
||||||
"url": "/taskmate/taskmate-family-goal-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-family-goal-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "e92ef0caff41454e9f49ea966ed99e41",
|
"id": "e92ef0caff41454e9f49ea966ed99e41",
|
||||||
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=178921037471",
|
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=1789210374100",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,7 +206,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d3c3e144a91247ce8ac524814737d7f8",
|
"id": "d3c3e144a91247ce8ac524814737d7f8",
|
||||||
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164651",
|
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164660",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -261,12 +256,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "8713ea9c008e4270bb28085eed5be959",
|
"id": "8713ea9c008e4270bb28085eed5be959",
|
||||||
"url": "/hacsfiles/Statistics-Graph-Chart-Card/statistics-graph-chart-card.js?hacstag=1034932856330",
|
"url": "/hacsfiles/Statistics-Graph-Chart-Card/statistics-graph-chart-card.js?hacstag=1034932856401",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "6c3f7d87683d4473b5dc8fe8177535f6",
|
"id": "6c3f7d87683d4473b5dc8fe8177535f6",
|
||||||
"url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807410",
|
"url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807420",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -286,16 +281,36 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
||||||
"url": "/taskmate/taskmate-routine-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-routine-card.js?v=5.0.4",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "af4aca87b12749c38fcd24cc20a0f3a0",
|
"id": "24acdbe1f5fb4bbdb2f16b69d515d549",
|
||||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.6.0",
|
"url": "/hacsfiles/ha-power-gauge/ha-power-gauge.js?hacstag=1233333695024",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d04da6163ea2434797129803283d9aa4",
|
"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": "b1ac81b582d14a7abd92ce53b5481b8a",
|
||||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||||
"type": "module"
|
"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.
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
# Home Assistant OpenCode Rules
|
||||||
|
|
||||||
|
You are working directly within a Home Assistant installation. Your working directory is `/homeassistant`, which is the live Home Assistant configuration directory.
|
||||||
|
|
||||||
|
## CRITICAL: User Consent and Scope Rules
|
||||||
|
|
||||||
|
You MUST follow these rules strictly:
|
||||||
|
|
||||||
|
1. **Never exceed the user's request** - Do exactly what the user asks, nothing more. Do not "improve" or "enhance" beyond the stated scope.
|
||||||
|
|
||||||
|
2. **Never make changes without explicit approval** - Before modifying ANY file:
|
||||||
|
- Show the user exactly what you plan to change
|
||||||
|
- Wait for their explicit confirmation ("yes", "go ahead", "do it", etc.)
|
||||||
|
- If they haven't approved, DO NOT proceed
|
||||||
|
|
||||||
|
3. **Ask, don't assume** - If the user's request is ambiguous:
|
||||||
|
- Ask clarifying questions first
|
||||||
|
- Present options and let them choose
|
||||||
|
- Never guess at their intent
|
||||||
|
|
||||||
|
4. **Read-only by default** - When investigating or troubleshooting:
|
||||||
|
- Only read files and gather information
|
||||||
|
- Present findings and recommendations
|
||||||
|
- Wait for user instruction before making any changes
|
||||||
|
|
||||||
|
5. **One change at a time** - When making approved changes:
|
||||||
|
- Make the minimum change needed
|
||||||
|
- Show what was changed
|
||||||
|
- Let the user verify before proceeding to any next step
|
||||||
|
|
||||||
|
6. **No unsolicited modifications** - Never:
|
||||||
|
- "Clean up" code the user didn't ask about
|
||||||
|
- Add features they didn't request
|
||||||
|
- Refactor working configurations
|
||||||
|
- Fix issues they haven't mentioned
|
||||||
|
|
||||||
|
7. **Respect "no"** - If a user declines a suggestion, do not:
|
||||||
|
- Repeat the suggestion
|
||||||
|
- Make the change anyway
|
||||||
|
- Try to convince them otherwise
|
||||||
|
|
||||||
|
## Environment Context
|
||||||
|
|
||||||
|
- You are running inside the OpenCode app
|
||||||
|
- The current directory (`/homeassistant`) contains the live Home Assistant configuration
|
||||||
|
- Changes to YAML files here directly affect the Home Assistant instance
|
||||||
|
- 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:
|
||||||
|
|
||||||
|
### 1. Configuration Files (YAML)
|
||||||
|
The standard way to define and customize Home Assistant behavior:
|
||||||
|
- Automations, scripts, scenes, and blueprints
|
||||||
|
- Integration and sensor configurations
|
||||||
|
- Templates, packages, and customizations
|
||||||
|
- Dashboard (Lovelace) definitions
|
||||||
|
|
||||||
|
These files are designed for user editing and are the source of truth for your Home Assistant setup.
|
||||||
|
|
||||||
|
### 2. MCP Tools (Runtime API)
|
||||||
|
Real-time interaction with the running Home Assistant instance:
|
||||||
|
- Query current entity states and history
|
||||||
|
- Control devices and call services
|
||||||
|
- Validate configurations
|
||||||
|
- Diagnose issues and detect anomalies
|
||||||
|
- Report OpenCode/HA agent capability status with `get_agent_capabilities`
|
||||||
|
|
||||||
|
### Native Home Assistant LLM Platform
|
||||||
|
Home Assistant is developing a native `llm` integration where Core integrations and custom integrations can expose curated tools through `<integration>/llm.py` and registered LLM APIs. New Home Assistant builds may also expose those APIs over native MCP endpoints such as `/api/mcp/<API ID>`; the built-in Assist API uses `/api/mcp/assist`. This is complementary to OpenCode MCP, not a replacement.
|
||||||
|
|
||||||
|
- If the optional `homeassistant_native` MCP server is available, prefer it for requests that fit the configured native Home Assistant LLM API because those tools are curated by Home Assistant.
|
||||||
|
- Use OpenCode MCP for configuration editing, safe writes, validation, admin/dev workflows, screenshots, updates, ESPHome, `hab`, Zigbee tasks, add-on development, and Home Assistant documentation lookup.
|
||||||
|
- Use `get_agent_capabilities` or `ha://agent/capabilities` to check whether the running HA instance reports the native `llm` component and native MCP endpoints.
|
||||||
|
- Use `get_home_context` for compact area/domain/entity understanding before broad state dumps.
|
||||||
|
- Use `get_ha_llm_development_guide` when helping develop or review a custom integration's native `<integration>/llm.py` provider.
|
||||||
|
- Do not assume this add-on can register tools directly with HA's native `llm` platform; native tool registration is internal to HA integrations/custom integrations. The add-on can consume configured native LLM APIs through native MCP when Home Assistant exposes them.
|
||||||
|
|
||||||
|
### 3. hab CLI (Home Assistant Builder)
|
||||||
|
A CLI tool designed for AI agents to manage Home Assistant. Run `hab` commands via the terminal:
|
||||||
|
- **Entity management**: `hab entity list`, `hab entity get light.living_room`, `hab entity logbook sensor.power --start 2h`
|
||||||
|
- **Service calls**: `hab action call light.turn_on --entity light.living_room --data '{"brightness": 200}'`
|
||||||
|
- **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"`
|
||||||
|
- **Helpers**: `hab helper list`, `hab helper create`
|
||||||
|
- **Scripts**: `hab script list`, `hab script create`
|
||||||
|
- **Scenes**: `hab scene list`, `hab scene create`, `hab scene activate "Movie Time"`
|
||||||
|
- **Blueprints**: `hab blueprint list`
|
||||||
|
- **Backups**: `hab backup list`, `hab backup create`
|
||||||
|
- **System**: `hab system info`, `hab system health`, `hab overview`
|
||||||
|
- **Devices**: `hab device list`
|
||||||
|
- **People**: `hab person list`, `hab person create`, `hab person update`
|
||||||
|
- **Categories**: `hab category list`, `hab category assign --entity light.kitchen`
|
||||||
|
- **To-do lists**: `hab todo list`, `hab todo item list todo.shopping`, `hab todo item add todo.shopping "Milk"`
|
||||||
|
- **Notifications**: `hab notification list`, `hab notification create --message "Hello" --title "Alert"`
|
||||||
|
- **Integrations**: `hab integration list`, `hab integration reload hue`, `hab integration disable mqtt`
|
||||||
|
- **Repairs**: `hab repairs list`, `hab repairs ignore <issue_id>`
|
||||||
|
- **Events**: `hab event list`, `hab event fire my_custom_event --data '{"key": "value"}'`
|
||||||
|
- **Templates**: `hab template render --expression "{{ states('sensor.temperature') }}"`
|
||||||
|
- **Search**: `hab search related`
|
||||||
|
|
||||||
|
`hab` outputs human-readable text by default. Use `--json` for structured JSON output (ideal for parsing).
|
||||||
|
`hab` is pre-authenticated via the Supervisor token - no login required.
|
||||||
|
Run `hab --help` or `hab <command> --help` for full usage details.
|
||||||
|
|
||||||
|
<!-- HAB_LIVE_HELP_START -->
|
||||||
|
```
|
||||||
|
Home Assistant Builder (hab) is a CLI utility designed for LLMs
|
||||||
|
to build and manage Home Assistant configurations.
|
||||||
|
|
||||||
|
Interactive sessions default to human-readable text. Non-interactive sessions default to JSON.
|
||||||
|
|
||||||
|
Start with 'hab guide' for workflow-level guidance optimized for LLM and agent usage.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
hab [command]
|
||||||
|
|
||||||
|
Getting Started:
|
||||||
|
auth Manage authentication
|
||||||
|
capability Inspect runtime capabilities
|
||||||
|
guide Display built-in usage guides
|
||||||
|
overview Show an overview of the Home Assistant instance
|
||||||
|
schema Show machine-readable command schema
|
||||||
|
|
||||||
|
Registry:
|
||||||
|
area Manage areas
|
||||||
|
device Manage devices
|
||||||
|
entity Manage entities
|
||||||
|
floor Manage floors
|
||||||
|
label Manage labels
|
||||||
|
person Manage persons
|
||||||
|
search Search for items and relationships
|
||||||
|
zone Manage zones
|
||||||
|
|
||||||
|
Automation:
|
||||||
|
action Call actions (services)
|
||||||
|
automation Manage automations
|
||||||
|
blueprint Manage blueprints
|
||||||
|
category Manage categories
|
||||||
|
helper Manage groups, templates, and other helpers
|
||||||
|
scene Manage scenes
|
||||||
|
script Manage scripts
|
||||||
|
|
||||||
|
Dashboard:
|
||||||
|
dashboard Manage dashboards
|
||||||
|
|
||||||
|
Other:
|
||||||
|
backup Manage backups
|
||||||
|
calendar Manage calendar events
|
||||||
|
diagnostics Manage diagnostics handlers
|
||||||
|
energy Manage energy dashboard settings
|
||||||
|
esphome Manage ESPHome devices
|
||||||
|
event Manage Home Assistant events
|
||||||
|
integration Manage integrations
|
||||||
|
network Manage network settings
|
||||||
|
notification Manage persistent notifications
|
||||||
|
repairs Manage Home Assistant repairs
|
||||||
|
system Manage system
|
||||||
|
template Work with Home Assistant templates
|
||||||
|
thread Manage Thread credentials
|
||||||
|
todo Manage to-do list items
|
||||||
|
update Update hab to the latest version
|
||||||
|
version Show version information
|
||||||
|
|
||||||
|
Additional Commands:
|
||||||
|
help Help about any command
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--config string Path to config directory (default: ~/.config/home-assistant-builder)
|
||||||
|
-h, --help help for hab
|
||||||
|
--json Use JSON output instead of human-readable text
|
||||||
|
--skip-update-check Skip automatic update check on startup
|
||||||
|
--text Use human-readable text output
|
||||||
|
--verbose Show verbose output
|
||||||
|
|
||||||
|
Use "hab [command] --help" for more information about a command.
|
||||||
|
```
|
||||||
|
<!-- HAB_LIVE_HELP_END -->
|
||||||
|
|
||||||
|
**Use configuration files when:** defining behavior, creating automations, setting up integrations
|
||||||
|
**Use MCP tools when:** checking current state, safe config writing, anomaly detection, entity diagnostics
|
||||||
|
**Use hab CLI when:** managing dashboards, areas, helpers, backups, blueprints, and bulk admin operations
|
||||||
|
|
||||||
|
### 4. zigporter CLI (Zigbee Toolkit)
|
||||||
|
A CLI for Zigbee device management in Home Assistant. Handles cascade renames (updating entity IDs across automations, scripts, scenes, and all Lovelace dashboards atomically), device inspection, stale device cleanup, and Zigbee mesh visualization.
|
||||||
|
|
||||||
|
**Cascade rename** — zigporter's unique value: when you rename an entity or device, it automatically patches every reference in automations, scripts, scenes, and Lovelace dashboards. `hab` can rename a single entity/device but does NOT cascade to references.
|
||||||
|
|
||||||
|
Key commands:
|
||||||
|
- **Cascade rename**: `zigporter rename-entity light.old_id light.new_id --apply`, `zigporter rename-device "Old Name" "New Name" --apply`
|
||||||
|
- **Device inventory**: `zigporter list-devices --json`, `zigporter list-z2m --json` (requires Z2M config)
|
||||||
|
- **Device inspection**: `zigporter inspect "Device Name" --json`, `zigporter inspect sensor.entity_id --json`
|
||||||
|
- **Stale device management**: `zigporter stale "Device" --action remove`, `zigporter stale "Device" --action ignore`
|
||||||
|
- **Post-migration cleanup**: `zigporter fix-device "Device" --apply`
|
||||||
|
- **Connectivity check**: `zigporter check`
|
||||||
|
- **ZHA export**: `zigporter export --output devices.json`
|
||||||
|
- **Mesh visualization**: `zigporter network-map --format table` (terminal), `zigporter network-map --output mesh.svg` (SVG file)
|
||||||
|
|
||||||
|
**Output format**: Use `--json` on listing/inspect commands for structured output (ideal for AI parsing). Rename commands output diffs and confirmation text.
|
||||||
|
|
||||||
|
zigporter is pre-authenticated via the Supervisor token. Z2M commands (`list-z2m`, `network-map --backend z2m`) require Z2M URL configuration in the add-on settings.
|
||||||
|
|
||||||
|
**Important limitations**:
|
||||||
|
- `rename-entity` / `rename-device` do NOT patch Jinja2 template expressions (e.g. `{{ states('old.id') }}`). A warning is printed listing affected files — inform the user these need manual review after renaming.
|
||||||
|
- The `migrate` command is inherently interactive (requires physical device actions) and must NOT be used by AI agents.
|
||||||
|
- Dry-run is the default for renames — always preview before using `--apply`.
|
||||||
|
|
||||||
|
<!-- ZIGPORTER_LIVE_HELP_START -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage: zigporter [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
Migrate Zigbee devices between ZHA and Zigbee2MQTT. Supports both ZHA → Z2M
|
||||||
|
(default) and Z2M → ZHA (--direction z2m-to-zha).
|
||||||
|
|
||||||
|
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ --version -v Show version and exit. │
|
||||||
|
│ --install-completion Install completion for the current shell. │
|
||||||
|
│ --show-completion Show completion for the current shell, to │
|
||||||
|
│ copy it or customize the installation. │
|
||||||
|
│ --help -h Show this message and exit. │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭─ Commands ───────────────────────────────────────────────────────────────────╮
|
||||||
|
│ setup Create or update the configuration file in the zigporter │
|
||||||
|
│ config directory. │
|
||||||
|
│ check Verify that all requirements are in place before migrating. │
|
||||||
|
│ export Export current ZHA devices, entities, areas, and automation │
|
||||||
|
│ references to JSON. │
|
||||||
|
│ export-z2m Export current Z2M devices, entities, areas, and automation │
|
||||||
|
│ references to JSON. │
|
||||||
|
│ list-z2m List all devices currently paired with Zigbee2MQTT. │
|
||||||
|
│ list-devices List all Home Assistant devices across all integrations. │
|
||||||
|
│ migrate Interactive wizard to migrate devices between ZHA and │
|
||||||
|
│ Zigbee2MQTT. │
|
||||||
|
│ inspect Show all automations, scripts, scenes, and dashboard cards │
|
||||||
|
│ that depend on a device. │
|
||||||
|
│ rename-entity Rename an entity ID and update all references in automations, │
|
||||||
|
│ scripts, scenes, and dashboards. │
|
||||||
|
│ rename-device Rename a device and cascade the change to all its entities, │
|
||||||
|
│ automations, scripts, scenes, and dashboards. │
|
||||||
|
│ stale Identify and manage offline/stale devices across all │
|
||||||
|
│ integrations. │
|
||||||
|
│ fix-device Remove stale ZHA device entries left behind after migration │
|
||||||
|
│ to Zigbee2MQTT. │
|
||||||
|
│ network-map Show Zigbee mesh topology with signal strength (LQI) for each │
|
||||||
|
│ device. │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
<!-- ZIGPORTER_LIVE_HELP_END -->
|
||||||
|
|
||||||
|
**Use zigporter CLI when:** renaming entities/devices with cascade updates, inspecting Zigbee devices across integrations, cleaning up stale or post-migration devices, visualizing the Zigbee mesh
|
||||||
|
|
||||||
|
### 5. Internal Directories (OFF-LIMITS)
|
||||||
|
Home Assistant manages internal state in directories like `.storage/`. These are:
|
||||||
|
- Not designed for direct access
|
||||||
|
- Subject to change without notice
|
||||||
|
- Potentially dangerous to modify
|
||||||
|
|
||||||
|
**Never access internal directories directly - use configuration files or MCP tools instead.**
|
||||||
|
|
||||||
|
## RESTRICTED: Internal Home Assistant Directories
|
||||||
|
|
||||||
|
**NEVER read, modify, or directly interact with these internal directories:**
|
||||||
|
|
||||||
|
| Directory | Contains | Use Instead |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `.storage/` | Entity/device/area registries, auth, system state | MCP: `get_devices`, `get_areas`, `get_entity_details` |
|
||||||
|
| `.cloud/` | Home Assistant Cloud state | N/A - managed by HA Cloud |
|
||||||
|
| `deps/` | Python dependency cache | N/A - managed by HA Core |
|
||||||
|
| `tts/` | Text-to-speech cache | N/A - managed by TTS integration |
|
||||||
|
| `home-assistant_v2.db` | History SQLite database | MCP: `get_history`, `get_logbook` |
|
||||||
|
| `home-assistant.log` | Raw system logs | MCP: `get_error_log` |
|
||||||
|
|
||||||
|
These contain internal Home Assistant state that:
|
||||||
|
1. Is managed exclusively by Home Assistant core
|
||||||
|
2. Can corrupt your installation if modified incorrectly
|
||||||
|
3. May be overwritten by Home Assistant at any time
|
||||||
|
4. Has no stable schema or format guarantees
|
||||||
|
|
||||||
|
**For information that seems to require internal access, there is always a proper alternative:**
|
||||||
|
- Need entity details? -> Read configuration files OR use `get_entity_details`
|
||||||
|
- Need device info? -> Use `get_devices` MCP tool
|
||||||
|
- Need to check history? -> Use `get_history` MCP tool
|
||||||
|
- Need to see errors? -> Use `get_error_log` MCP tool
|
||||||
|
|
||||||
|
## File Structure Knowledge
|
||||||
|
|
||||||
|
### Configuration Files (Primary Interface - Read/Write with User Approval)
|
||||||
|
These are the user-facing configuration files - the primary way to define Home Assistant behavior:
|
||||||
|
|
||||||
|
- `configuration.yaml` - Main configuration file
|
||||||
|
- `automations.yaml` - Automation definitions (if using UI or split config)
|
||||||
|
- `scripts.yaml` - Script definitions
|
||||||
|
- `scenes.yaml` - Scene definitions
|
||||||
|
- `secrets.yaml` - Sensitive values (NEVER commit or expose)
|
||||||
|
- `customize.yaml` - Entity customizations
|
||||||
|
- `groups.yaml` - Group definitions
|
||||||
|
- `packages/` - Package-based configuration splits
|
||||||
|
- `blueprints/` - Automation and script blueprints
|
||||||
|
- `custom_components/` - Custom integrations (HACS or manual)
|
||||||
|
- `www/` - Static files served at /local/
|
||||||
|
- `themes/` - Custom themes
|
||||||
|
- `*.yaml` in root - Any user-created YAML configuration
|
||||||
|
|
||||||
|
**These files are designed for editing** and are equally valid as MCP tools for research and changes.
|
||||||
|
|
||||||
|
### Internal Directories (OFF-LIMITS - Never Access Directly)
|
||||||
|
- `.storage/` - Internal registries and state (use MCP tools)
|
||||||
|
- `.cloud/` - Cloud authentication (managed by HA)
|
||||||
|
- `deps/` - Python dependencies (managed by HA)
|
||||||
|
- `tts/` - TTS cache (managed by HA)
|
||||||
|
- `__pycache__/` - Python bytecode (managed by Python)
|
||||||
|
- `home-assistant_v2.db` - History database (use MCP `get_history`)
|
||||||
|
- `home-assistant.log` - Logs (use MCP `get_error_log`)
|
||||||
|
|
||||||
|
## Working with YAML on the Command Line (`yq`)
|
||||||
|
|
||||||
|
To read, query, or convert YAML from the shell, use **`yq`** (the mikefarah/Go tool, pre-installed on `PATH`). It is the correct tool because it tolerates Home Assistant's custom tags — `!include`, `!secret`, `!env_var`, `!input`, and the `!include_dir_*` family.
|
||||||
|
|
||||||
|
**Do NOT reach for `python3 -c "import yaml"` (PyYAML) or Ruby's YAML for HA config** — both crash with a constructor error on the very first `!include`/`!secret`, because those tags are Home Assistant extensions, not standard YAML. `yq` parses them with no setup.
|
||||||
|
|
||||||
|
### Read / query (always safe — never errors on HA tags)
|
||||||
|
|
||||||
|
```
|
||||||
|
yq '.homeassistant.latitude' configuration.yaml # Print a nested value
|
||||||
|
yq '.automation | tag' configuration.yaml # Inspect the tag itself -> !include
|
||||||
|
yq 'keys' configuration.yaml # List top-level keys
|
||||||
|
yq -o=json '.' configuration.yaml | jq '.sensor' # Convert to JSON to pipe into jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: output and JSON conversion strip the tag — `!secret home_latitude` prints as `home_latitude`. Use `| tag` when you need to see the tag. Never round-trip a file *through* JSON and back; that permanently loses every `!include`/`!secret`.
|
||||||
|
|
||||||
|
### Writing / editing
|
||||||
|
|
||||||
|
Prefer the sanctioned write path: **`write_config_safe`** (MCP — validates, backs up, and blocks accidental content loss) or read the full file and use the editor. Reserve `yq -i` for quick, low-risk edits, and only with these two caveats in mind:
|
||||||
|
|
||||||
|
1. **A custom tag sticks to the value you overwrite.** `yq -i '.homeassistant.latitude = 52.37'` on a `!secret`-tagged node produces the corrupt `latitude: !secret 52.37`. When replacing a tagged value, reset the tag in the *same* expression:
|
||||||
|
```
|
||||||
|
yq -i '(.homeassistant.latitude tag = "") | .homeassistant.latitude = 52.37' configuration.yaml
|
||||||
|
```
|
||||||
|
To *add* a secret reference, set the tag explicitly: `yq -i '.http.api_key = "my_api_key" | .http.api_key tag = "!secret"' configuration.yaml`
|
||||||
|
2. **`yq -i` strips blank separator lines** (and collapses inline-comment spacing) across the whole file. No data is lost, but diffs are noisier. When a clean, minimal diff matters, use the editor instead.
|
||||||
|
|
||||||
|
### Validation is not a syntax check
|
||||||
|
|
||||||
|
`yq` only confirms YAML *parses*. To validate a Home Assistant *configuration* (resolving `!include`/`!secret` and checking integration schemas), use `check_config_syntax` / `write_config_safe` (MCP), not `yq`.
|
||||||
|
|
||||||
|
## YAML Style Guide (MANDATORY)
|
||||||
|
|
||||||
|
All YAML written or modified MUST follow the official Home Assistant YAML Style Guide.
|
||||||
|
Reference: https://developers.home-assistant.io/docs/documenting/yaml-style-guide/
|
||||||
|
|
||||||
|
A Prettier formatter is configured for this environment and will auto-format files on save.
|
||||||
|
However, Prettier only enforces a subset of the rules below. You are responsible for following
|
||||||
|
ALL rules, especially those Prettier cannot enforce (marked with *).
|
||||||
|
|
||||||
|
### Indentation
|
||||||
|
|
||||||
|
2 spaces. Tabs are forbidden.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
example:
|
||||||
|
one: 1
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
example:
|
||||||
|
bad: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Booleans *
|
||||||
|
|
||||||
|
Only `true` and `false` in lowercase. Never use `Yes`, `No`, `On`, `Off`, `TRUE`, etc.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
one: true
|
||||||
|
two: false
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
one: True
|
||||||
|
two: on
|
||||||
|
three: yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strings
|
||||||
|
|
||||||
|
Double quotes for strings. Single quotes are not allowed.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
example: "Hi there!"
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
example: 'Hi there!'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exceptions** (no quotes needed): entity IDs, area IDs, device IDs, platform types,
|
||||||
|
trigger types, condition types, action names, device classes, event names, attribute names,
|
||||||
|
and values from a fixed set of options (e.g., `mode`).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
actions:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room
|
||||||
|
area_id: living_room
|
||||||
|
data:
|
||||||
|
message: "Hello!"
|
||||||
|
transition: 10
|
||||||
|
|
||||||
|
# Bad - don't quote entity IDs and action names
|
||||||
|
actions:
|
||||||
|
- action: "light.turn_on"
|
||||||
|
target:
|
||||||
|
entity_id: "light.living_room"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequences (Lists) *
|
||||||
|
|
||||||
|
Use block style. Flow style `[1, 2, 3]` must not be used.
|
||||||
|
Block sequences must be indented under their key.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
options:
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
options: [1, 2, 3]
|
||||||
|
|
||||||
|
# Bad - not indented under key
|
||||||
|
options:
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mappings *
|
||||||
|
|
||||||
|
Block style only. Flow style `{ key: val }` must not be used.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
example:
|
||||||
|
one: 1
|
||||||
|
two: 2
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
example: { one: 1, two: 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Null Values *
|
||||||
|
|
||||||
|
Use implicit null (just `key:` with no value). Never use `null` or `~`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
initial:
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
initial: null
|
||||||
|
initial: ~
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
Capitalized, with a space after `#`, indented to match current level.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
example:
|
||||||
|
# This is a comment
|
||||||
|
one: true
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
example:
|
||||||
|
# Comment at wrong indent
|
||||||
|
#Missing space
|
||||||
|
#lowercase start
|
||||||
|
one: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiline Strings
|
||||||
|
|
||||||
|
Use literal `|` (preserves newlines) or folded `>` (joins lines) block scalars.
|
||||||
|
Avoid `\n` in strings. Prefer no-chomp (`|`, `>`) unless you need strip (`|-`, `>-`).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
message: |
|
||||||
|
Hello!
|
||||||
|
This is a multiline
|
||||||
|
notification message.
|
||||||
|
|
||||||
|
# Good - folded
|
||||||
|
description: >
|
||||||
|
This is a long description that
|
||||||
|
will be joined into a single line.
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
message: "Hello!\nThis is a multiline\nnotification message.\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
|
||||||
|
Double quotes outside, single quotes inside. Use `states()` and `state_attr()`
|
||||||
|
helpers, not direct state object access. Split long templates across multiple lines.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
value_template: "{{ states('sensor.temperature') }}"
|
||||||
|
attribute_template: "{{ state_attr('climate.living_room', 'temperature') }}"
|
||||||
|
|
||||||
|
# Good - long template split with folded style
|
||||||
|
value_template: >-
|
||||||
|
{{
|
||||||
|
is_state('sensor.bedroom_co_status', 'Ok')
|
||||||
|
and is_state('sensor.kitchen_co_status', 'Ok')
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Bad - single quotes outside
|
||||||
|
value_template: '{{ "some_value" == other_value }}'
|
||||||
|
|
||||||
|
# Bad - direct state object access
|
||||||
|
value_template: "{{ states.sensor.temperature.state }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Action Targets *
|
||||||
|
|
||||||
|
Always use `target:` for entity/device/area targeting. Do not put `entity_id` at
|
||||||
|
the action level or inside `data:`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
actions:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
actions:
|
||||||
|
- action: light.turn_on
|
||||||
|
entity_id: light.living_room
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
actions:
|
||||||
|
- action: light.turn_on
|
||||||
|
data:
|
||||||
|
entity_id: light.living_room
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scalar vs List *
|
||||||
|
|
||||||
|
If a property accepts both, use a scalar for single values. Do not wrap a single
|
||||||
|
value in a list. Do not use comma-separated strings.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
entity_id: light.living_room
|
||||||
|
entity_id:
|
||||||
|
- light.living_room
|
||||||
|
- light.office
|
||||||
|
|
||||||
|
# Bad - single value in a list
|
||||||
|
entity_id:
|
||||||
|
- light.living_room
|
||||||
|
|
||||||
|
# Bad - comma separated
|
||||||
|
entity_id: "light.living_room, light.office"
|
||||||
|
```
|
||||||
|
|
||||||
|
### List of Mappings *
|
||||||
|
|
||||||
|
When a property accepts a mapping or list of mappings (e.g., `actions`, `conditions`),
|
||||||
|
always use a list even for a single item.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good
|
||||||
|
actions:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room
|
||||||
|
|
||||||
|
# Bad
|
||||||
|
actions:
|
||||||
|
action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Competencies
|
||||||
|
|
||||||
|
### YAML Configuration
|
||||||
|
- Follow the YAML Style Guide above for ALL configuration changes
|
||||||
|
- Use anchors (`&name`) and aliases (`*name`) for DRY configurations
|
||||||
|
- Understand `!include`, `!include_dir_named`, `!include_dir_list`, `!include_dir_merge_named`, `!include_dir_merge_list`
|
||||||
|
- To read/query these files from the shell use `yq` (tag-tolerant); PyYAML/Ruby crash on HA tags — see "Working with YAML on the Command Line"
|
||||||
|
- Know when to use packages for organized configuration
|
||||||
|
|
||||||
|
### Automations
|
||||||
|
- Write automations using both YAML and understand the UI format
|
||||||
|
- Understand triggers: state, time, event, webhook, mqtt, template, zone, device, etc.
|
||||||
|
- Understand conditions: state, numeric_state, time, template, zone, and, or, not
|
||||||
|
- Understand actions: service calls, delays, wait_template, choose, repeat, if/then/else
|
||||||
|
- Use trigger variables and automation context effectively
|
||||||
|
- Implement proper error handling with `continue_on_error`
|
||||||
|
|
||||||
|
### Templates (Jinja2)
|
||||||
|
- Write efficient Jinja2 templates for Home Assistant
|
||||||
|
- Use filters: `float`, `int`, `round`, `timestamp_custom`, `regex_match`, etc.
|
||||||
|
- Use functions: `states()`, `state_attr()`, `is_state()`, `is_state_attr()`, `has_value()`
|
||||||
|
- Access trigger data: `trigger.to_state`, `trigger.from_state`, `trigger.entity_id`
|
||||||
|
- Handle unavailable/unknown states gracefully
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
- Know common integrations and their configuration patterns
|
||||||
|
- Understand MQTT, REST, and template-based integrations
|
||||||
|
- Configure input_* helpers: input_boolean, input_number, input_select, input_text, input_datetime
|
||||||
|
- Set up utility_meter, statistics, and history_stats sensors
|
||||||
|
|
||||||
|
### Lovelace Dashboards
|
||||||
|
- Write Lovelace YAML configurations
|
||||||
|
- Know standard cards and their options
|
||||||
|
- Understand conditional cards, custom cards, and card-mod
|
||||||
|
- Configure views, themes, and resources
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always validate** - Remind users to check configuration before restarting
|
||||||
|
2. **Use secrets** - Never hardcode sensitive data; use `!secret` references
|
||||||
|
3. **Backup first** - Suggest backups before major changes
|
||||||
|
4. **Incremental changes** - Make small, testable changes
|
||||||
|
5. **Comments** - Add YAML comments explaining complex logic
|
||||||
|
6. **Naming conventions** - Use consistent entity_id naming (e.g., `sensor.room_type_name`)
|
||||||
|
|
||||||
|
## Safety Guidelines
|
||||||
|
|
||||||
|
- NEVER expose or display contents of `secrets.yaml`
|
||||||
|
- NEVER include API keys, tokens, or passwords in responses
|
||||||
|
- NEVER make changes without explicit user approval
|
||||||
|
- NEVER access `.storage/`, `.cloud/`, or other internal directories
|
||||||
|
- NEVER attempt to modify Home Assistant's internal databases or registries
|
||||||
|
- NEVER parse internal JSON files for entity/device/area information
|
||||||
|
- ALWAYS prefer MCP tools for querying runtime state over internal file access
|
||||||
|
- ALWAYS use `call_service` through MCP rather than modifying state files
|
||||||
|
- WARN users before changes that require restart vs reload
|
||||||
|
- SUGGEST backing up files before major modifications
|
||||||
|
- CHECK configuration validity when possible
|
||||||
|
- ALWAYS confirm with user before writing, editing, or deleting any file
|
||||||
|
|
||||||
|
## MCP Tools and Configuration Files
|
||||||
|
|
||||||
|
You have two complementary interfaces for working with Home Assistant:
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
Read and modify YAML files to understand and change Home Assistant's defined behavior:
|
||||||
|
- Review `automations.yaml` to understand existing automations
|
||||||
|
- Edit `configuration.yaml` to add new integrations
|
||||||
|
- Create new files in `packages/` for organized configuration
|
||||||
|
- Examine `custom_components/` for custom integration code
|
||||||
|
|
||||||
|
### MCP Tools (When Available)
|
||||||
|
Query and interact with the running Home Assistant instance:
|
||||||
|
- `get_states`, `search_entities`, `get_home_context` - Current entity states and compact area/domain/entity context
|
||||||
|
- `call_service` - Control devices (with confirmation), 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
|
||||||
|
- `watch_firmware_update` - **Real-time firmware update monitoring** (ESPHome, WLED, Zigbee, etc.)
|
||||||
|
- `get_available_updates`, `update_component` - System update management
|
||||||
|
- `screenshot_url` - **Visual verification** of dashboards and UI pages (requires `screenshot_enabled` option)
|
||||||
|
|
||||||
|
### Choosing the Right Approach
|
||||||
|
|
||||||
|
| Task | Configuration Files | MCP Tools | hab CLI | zigporter CLI |
|
||||||
|
|------|---------------------|-----------|---------|---------------|
|
||||||
|
| Create/edit automations | Primary | **Write with `write_config_safe`** | `hab automation create` | N/A |
|
||||||
|
| Understand automation logic | Read YAML | Check state with `get_states` | `hab automation get` | N/A |
|
||||||
|
| Check current device state | Reference only | Primary (`get_home_context` for focused context) | `hab entity get` | N/A |
|
||||||
|
| Control devices | N/A | `call_service` | `hab action call` | N/A |
|
||||||
|
| 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`, `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 |
|
||||||
|
| View history | N/A | `get_history` | N/A | N/A |
|
||||||
|
| **Manage dashboards** | Edit YAML | N/A | **`hab dashboard` (primary)** | N/A |
|
||||||
|
| **Verify UI changes** | N/A | **`screenshot_url`** | N/A | N/A |
|
||||||
|
| **Manage areas/floors** | N/A | `get_areas` (read-only) | **`hab area/floor` (CRUD)** | N/A |
|
||||||
|
| **Manage helpers** | N/A | N/A | **`hab helper` (primary)** | N/A |
|
||||||
|
| **Backups** | N/A | `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 |
|
||||||
|
| **Update HA Core/OS** | N/A | `update_component` | N/A | N/A |
|
||||||
|
| **Rename entity with cascade** | N/A | N/A | `hab entity update` (no cascade) | **`zigporter rename-entity` (primary)** |
|
||||||
|
| **Rename device with cascade** | N/A | N/A | `hab device update` (no cascade) | **`zigporter rename-device` (primary)** |
|
||||||
|
| **Inspect Zigbee device** | N/A | `get_entity_details` | `hab device list` | **`zigporter inspect --json` (cross-ref ZHA+Z2M+HA)** |
|
||||||
|
| **List Z2M devices** | N/A | N/A | N/A | **`zigporter list-z2m --json`** |
|
||||||
|
| **Clean up stale devices** | N/A | N/A | N/A | **`zigporter stale --action`** |
|
||||||
|
| **Fix post-migration entities** | N/A | N/A | N/A | **`zigporter fix-device --apply`** |
|
||||||
|
| **Zigbee mesh topology** | N/A | N/A | N/A | **`zigporter network-map`** |
|
||||||
|
|
||||||
|
### Update Management (IMPORTANT)
|
||||||
|
|
||||||
|
**For device firmware updates (ESPHome, WLED, Zigbee, etc.):**
|
||||||
|
Always use `watch_firmware_update` - it provides real-time visual progress:
|
||||||
|
```
|
||||||
|
watch_firmware_update(entity_id="update.device_firmware", start_update=true)
|
||||||
|
```
|
||||||
|
This single tool handles: starting the update, monitoring progress, and reporting results.
|
||||||
|
|
||||||
|
**For system updates (Core, OS, Supervisor, Apps):**
|
||||||
|
```
|
||||||
|
1. get_available_updates() -> Check what needs updating
|
||||||
|
2. update_component(component="core") -> Start update (returns job_id)
|
||||||
|
3. get_update_progress(job_id="...") -> Monitor progress
|
||||||
|
```
|
||||||
|
|
||||||
|
**Both approaches are valid and complementary.** Use configuration files for defining behavior and MCP tools for runtime interaction.
|
||||||
|
|
||||||
|
## Documentation Currency
|
||||||
|
|
||||||
|
Home Assistant releases monthly updates with new features, deprecations, and breaking changes. Your training data may be outdated. **Always verify configuration syntax against current documentation.**
|
||||||
|
|
||||||
|
### Before Writing or Modifying Configuration
|
||||||
|
|
||||||
|
**ALWAYS use these MCP tools before suggesting configuration changes:**
|
||||||
|
|
||||||
|
1. **Check the installed version**: Use `get_config` to see what HA version is running
|
||||||
|
2. **Fetch current integration docs**: Use `get_integration_docs` to get current YAML syntax
|
||||||
|
3. **Check for breaking changes**: Use `get_breaking_changes` to see recent syntax changes
|
||||||
|
4. **Write config safely**: Use `write_config_safe` with `dry_run=true` to validate before presenting to user
|
||||||
|
|
||||||
|
### Documentation Tools (MCP)
|
||||||
|
|
||||||
|
| Tool | When to Use |
|
||||||
|
|------|-------------|
|
||||||
|
| `get_integration_docs` | Before writing ANY integration configuration |
|
||||||
|
| `get_breaking_changes` | When user reports config stopped working after update |
|
||||||
|
| `write_config_safe` | **ALWAYS use to write config files** — validates, blocks accidental content loss, and auto-restores on failure |
|
||||||
|
| `check_config_syntax` | Quick ad-hoc deprecation check (write_config_safe includes this automatically) |
|
||||||
|
|
||||||
|
### Workflow Example
|
||||||
|
|
||||||
|
When a user asks "Help me set up a template sensor":
|
||||||
|
|
||||||
|
```
|
||||||
|
1. get_config() -> Check HA version (e.g., 2024.12.1)
|
||||||
|
2. get_integration_docs("template") -> Get current syntax and examples
|
||||||
|
3. read_file(path) -> Read the EXISTING file content first
|
||||||
|
4. Draft configuration: include ALL existing content + new changes
|
||||||
|
5. write_config_safe(path, yaml, dry_run=true) -> Pre-validate everything
|
||||||
|
6. If errors: fix and repeat step 5
|
||||||
|
7. Present validated config to user and get approval
|
||||||
|
8. write_config_safe(path, yaml) -> Write for real (auto backup + validation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Deprecation Patterns
|
||||||
|
|
||||||
|
Be especially careful with these frequently-changed areas:
|
||||||
|
- **Template sensors/binary_sensors**: `platform: template` under `sensor:` is deprecated; use top-level `template:`
|
||||||
|
- **Entity configurations**: Many moved from YAML to UI-based config
|
||||||
|
- **Trigger-based templates**: Newer syntax preferred over legacy template sensors
|
||||||
|
- **Device triggers**: Syntax evolves with new device types
|
||||||
|
- **MQTT platform syntax**: `platform: mqtt` under domain keys is deprecated; use top-level `mqtt:` key
|
||||||
|
- **Direct state access**: `states.sensor.x.state` is fragile; use `states('sensor.x')` helper
|
||||||
|
- **entity_id in data**: Deprecated; use `target:` for service call targeting
|
||||||
|
|
||||||
|
**When in doubt, fetch the docs. Never rely solely on training data for configuration syntax.**
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Creating an Automation
|
||||||
|
1. **Read the existing `automations.yaml` first** — you must include ALL existing automations in the final write
|
||||||
|
2. Understand the goal and identify trigger conditions
|
||||||
|
3. Determine required entities (search if MCP available)
|
||||||
|
4. Draft the automation YAML with clear comments
|
||||||
|
5. **Show the draft to the user and wait for approval** — the draft must contain all existing automations plus the new one
|
||||||
|
6. Only write the file after explicit user confirmation
|
||||||
|
7. Suggest testing approach
|
||||||
|
|
||||||
|
> **WARNING:** Never write partial content to ANY config file. Always read the existing file first and include ALL existing content in your write. `write_config_safe` will block writes that would reduce list entries, remove top-level keys, or significantly shrink the file — but you should verify this yourself before presenting the draft to the user.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
1. Check entity states and history (via MCP if available)
|
||||||
|
2. Review relevant configuration files
|
||||||
|
3. Check Home Assistant logs for errors
|
||||||
|
4. Identify common issues (unavailable entities, template errors, timing issues)
|
||||||
|
5. **Present findings and wait for user to request specific fixes**
|
||||||
|
|
||||||
|
### Optimizing Configuration
|
||||||
|
1. Identify redundant or inefficient patterns
|
||||||
|
2. **Present recommendations to user**
|
||||||
|
3. Wait for user to approve specific changes
|
||||||
|
4. Implement only the changes the user explicitly approves
|
||||||
@@ -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
|
||||||
+2747
-2431
File diff suppressed because it is too large
Load Diff
+124
-6
@@ -1,9 +1,19 @@
|
|||||||
# Loads default set of integrations. Do not remove.
|
# Loads default set of integrations. Do not remove.
|
||||||
default_config:
|
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)
|
# Core Utility Meter Engine (Required for Hilo to generate compatible energy dashboard metrics)
|
||||||
utility_meter:
|
utility_meter:
|
||||||
|
|
||||||
|
# Shell Commands
|
||||||
|
shell_command:
|
||||||
|
cleanup_blink_snapshots: "find /media /config/www -name 'blink_*.jpg' -mmin +360 -delete"
|
||||||
|
|
||||||
# Load ffmpeg
|
# Load ffmpeg
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
|
|
||||||
@@ -171,12 +181,55 @@ template:
|
|||||||
{{ states('sensor.blink_backyard_temperature_clean') }}
|
{{ states('sensor.blink_backyard_temperature_clean') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
# HTTP Proxy Configuration
|
# POOL PUMP SENSOR: Converts kW sensor to Watts for Watt-based gauge cards
|
||||||
http:
|
- name: "Pool Pump Estimated Power Draw Watts"
|
||||||
use_x_forwarded_for: true
|
unique_id: pool_pump_estimated_power_draw_watts
|
||||||
trusted_proxies:
|
unit_of_measurement: "W"
|
||||||
- 192.168.122.1
|
device_class: power
|
||||||
- 192.168.1.0/24
|
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
|
# Legacy and Integration Sensor Section
|
||||||
sensor:
|
sensor:
|
||||||
@@ -200,6 +253,66 @@ sensor:
|
|||||||
method: left
|
method: left
|
||||||
round: 2
|
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"
|
||||||
|
|
||||||
|
# OctoPrint physical position (holds last value when idle; raw discovery sensor zeroes on currentZ:null)
|
||||||
|
mqtt:
|
||||||
|
sensor:
|
||||||
|
- name: "OctoPrint 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"
|
||||||
|
|
||||||
emulated_hue:
|
emulated_hue:
|
||||||
host_ip: 192.168.1.140
|
host_ip: 192.168.1.140
|
||||||
advertise_ip: 192.168.1.140
|
advertise_ip: 192.168.1.140
|
||||||
@@ -209,3 +322,8 @@ emulated_hue:
|
|||||||
input_boolean.cync_motion_bridge:
|
input_boolean.cync_motion_bridge:
|
||||||
name: "Cync Motion Bridge"
|
name: "Cync Motion Bridge"
|
||||||
hidden: false
|
hidden: false
|
||||||
|
|
||||||
|
nest_snapshot:
|
||||||
|
|
||||||
|
# G-code proxy for the 3D toolpath viewer card (www/gcode_viewer_card.js)
|
||||||
|
octoprint_gcode_proxy:
|
||||||
|
|||||||
@@ -430,6 +430,11 @@ class AlarmoCoordinator(DataUpdateCoordinator):
|
|||||||
)
|
)
|
||||||
return
|
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 = (
|
arm_mode = (
|
||||||
alarm_entity._revert_state
|
alarm_entity._revert_state
|
||||||
if alarm_entity._revert_state in const.ARM_MODES
|
if alarm_entity._revert_state in const.ARM_MODES
|
||||||
@@ -452,9 +457,6 @@ class AlarmoCoordinator(DataUpdateCoordinator):
|
|||||||
elif action == const.EVENT_ACTION_RETRY_ARM:
|
elif action == const.EVENT_ACTION_RETRY_ARM:
|
||||||
_LOGGER.info("Received request for retry arming")
|
_LOGGER.info("Received request for retry arming")
|
||||||
await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
|
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:
|
else:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Received request for arming with mode %s",
|
"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
|
async def _validate_code(self, code, to_state): # noqa PLR0911
|
||||||
"""Validate code and user permissions for a requested state change.
|
"""Validate code and user permissions for a requested state change.
|
||||||
|
|
||||||
Returns a (success, error_event) tuple. When success is True,
|
Returns a (success, info) tuple.
|
||||||
error_event is None.
|
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
|
# check bypass rules
|
||||||
if (
|
if (
|
||||||
@@ -404,7 +407,7 @@ class AlarmoBaseEntity(AlarmControlPanelEntity, RestoreEntity):
|
|||||||
|
|
||||||
# success
|
# success
|
||||||
self._changed_by = user[ATTR_NAME]
|
self._changed_by = user[ATTR_NAME]
|
||||||
return True, None
|
return True, user
|
||||||
|
|
||||||
async def async_service_disarm_handler(self, code, context_id=None):
|
async def async_service_disarm_handler(self, code, context_id=None):
|
||||||
"""Handle external disarm request from alarmo.disarm service."""
|
"""Handle external disarm request from alarmo.disarm service."""
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ def validate_area(trigger, area_id, hass):
|
|||||||
return False
|
return False
|
||||||
elif trigger[const.ATTR_AREA]:
|
elif trigger[const.ATTR_AREA]:
|
||||||
return trigger[const.ATTR_AREA] == area_id
|
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:
|
elif len(hass.data[const.DOMAIN]["areas"]) == 1:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import (
|
|||||||
AlarmControlPanelEntityFeature,
|
AlarmControlPanelEntityFeature,
|
||||||
)
|
)
|
||||||
|
|
||||||
VERSION = "1.10.18"
|
VERSION = "1.10.19"
|
||||||
NAME = "Alarmo"
|
NAME = "Alarmo"
|
||||||
MANUFACTURER = "@nielsfaber"
|
MANUFACTURER = "@nielsfaber"
|
||||||
|
|
||||||
|
|||||||
+448
-448
File diff suppressed because one or more lines are too long
@@ -17,5 +17,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/nielsfaber/alarmo/issues",
|
"issue_tracker": "https://github.com/nielsfaber/alarmo/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "1.10.18"
|
"version": "1.10.19"
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,11 @@ class SensorHandler:
|
|||||||
|
|
||||||
def __init__(self, hass: HomeAssistant):
|
def __init__(self, hass: HomeAssistant):
|
||||||
"""Initialize the sensor handler."""
|
"""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.hass = hass
|
||||||
self._state_listener = None
|
self._state_listener = None
|
||||||
self._subscriptions = []
|
self._subscriptions = []
|
||||||
@@ -144,9 +148,10 @@ class SensorHandler:
|
|||||||
@callback
|
@callback
|
||||||
def async_update_sensor_config():
|
def async_update_sensor_config():
|
||||||
"""Sensor config updated, reload the configuration."""
|
"""Sensor config updated, reload the configuration."""
|
||||||
self._config = self.hass.data[const.DOMAIN][
|
self._config = (
|
||||||
"coordinator"
|
self.hass.data[const.DOMAIN]["coordinator"].store.async_get_sensors()
|
||||||
].store.async_get_sensors()
|
or {}
|
||||||
|
)
|
||||||
self._groups = self.hass.data[const.DOMAIN][
|
self._groups = self.hass.data[const.DOMAIN][
|
||||||
"coordinator"
|
"coordinator"
|
||||||
].store.async_get_sensor_groups()
|
].store.async_get_sensor_groups()
|
||||||
@@ -377,15 +382,28 @@ class SensorHandler:
|
|||||||
new_state = parse_sensor_state(event.data["new_state"])
|
new_state = parse_sensor_state(event.data["new_state"])
|
||||||
sensor_config = self._config[entity]
|
sensor_config = self._config[entity]
|
||||||
if old_state == STATE_UNKNOWN:
|
if old_state == STATE_UNKNOWN:
|
||||||
# sensor is unknown at startup,
|
if new_state not in (STATE_OPEN, STATE_UNAVAILABLE) or (
|
||||||
# state which comes after is considered as initial state
|
sensor_config[ATTR_ALLOW_OPEN] and new_state == STATE_OPEN
|
||||||
_LOGGER.debug(
|
):
|
||||||
"Initial state for %s is %s",
|
# transition to a safe state, or to open while the sensor is
|
||||||
entity,
|
# allowed to be open — treat as initial state
|
||||||
new_state,
|
_LOGGER.debug(
|
||||||
)
|
"Initial state for %s is %s",
|
||||||
self.update_ready_to_arm_status(sensor_config["area"])
|
entity,
|
||||||
return
|
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:
|
if old_state == new_state:
|
||||||
# not a state change - ignore
|
# not a state change - ignore
|
||||||
return
|
return
|
||||||
@@ -740,6 +758,13 @@ class SensorHandler:
|
|||||||
# Skip unknown sensors - they'll be handled when they become known
|
# Skip unknown sensors - they'll be handled when they become known
|
||||||
continue
|
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
|
# Check if sensor state is allowed in current alarm state
|
||||||
res = sensor_state_allowed(sensor_state, sensor_config, alarm_entity.state)
|
res = sensor_state_allowed(sensor_state, sensor_config, alarm_entity.state)
|
||||||
|
|
||||||
|
|||||||
@@ -381,10 +381,10 @@ class AlarmoStorage:
|
|||||||
for area in data["areas"]:
|
for area in data["areas"]:
|
||||||
modes = {
|
modes = {
|
||||||
mode: ModeEntry(
|
mode: ModeEntry(
|
||||||
enabled=config["enabled"],
|
enabled=config.get("enabled", False),
|
||||||
exit_time=config["exit_time"],
|
exit_time=config.get("exit_time", None),
|
||||||
entry_time=config["entry_time"],
|
entry_time=config.get("entry_time", None),
|
||||||
trigger_time=config["trigger_time"],
|
trigger_time=config.get("trigger_time", None),
|
||||||
)
|
)
|
||||||
for (mode, config) in area["modes"].items()
|
for (mode, config) in area["modes"].items()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from .const import (
|
|||||||
CONF_ICLOUD_TOKEN,
|
CONF_ICLOUD_TOKEN,
|
||||||
CONF_ICLOUD_IMAGE_SIZE,
|
CONF_ICLOUD_IMAGE_SIZE,
|
||||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||||
|
CONF_ICLOUD_BACKEND,
|
||||||
ICLOUD_IMAGE_FULL,
|
ICLOUD_IMAGE_FULL,
|
||||||
ICLOUD_IMAGE_PREVIEW,
|
ICLOUD_IMAGE_PREVIEW,
|
||||||
CONF_SYNOLOGY_URL,
|
CONF_SYNOLOGY_URL,
|
||||||
@@ -219,7 +220,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if not ALBUM_URL_RE.match(url):
|
if not ALBUM_URL_RE.match(url):
|
||||||
errors[CONF_ALBUM_URL] = "invalid_album_url"
|
errors[CONF_ALBUM_URL] = "invalid_album_url"
|
||||||
else:
|
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()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=name,
|
title=name,
|
||||||
@@ -249,7 +250,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if not path:
|
if not path:
|
||||||
errors[CONF_LOCAL_PATH] = "invalid_path"
|
errors[CONF_LOCAL_PATH] = "invalid_path"
|
||||||
else:
|
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()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=name,
|
title=name,
|
||||||
@@ -283,7 +284,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
|
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(
|
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()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
@@ -395,7 +396,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
sel_id = json.dumps(selection, sort_keys=True)
|
sel_id = json.dumps(selection, sort_keys=True)
|
||||||
unique = (
|
unique = (
|
||||||
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
|
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)
|
await self.async_set_unique_id(unique)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
@@ -574,7 +575,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
sel_id = json.dumps(selection, sort_keys=True)
|
sel_id = json.dumps(selection, sort_keys=True)
|
||||||
unique = (
|
unique = (
|
||||||
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
|
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)
|
await self.async_set_unique_id(unique)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
@@ -659,18 +660,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
from . import icloud as icloud_api
|
from . import icloud as icloud_api
|
||||||
|
|
||||||
token = icloud_api.parse_share_link(raw_url)
|
parsed = icloud_api.parse_share(raw_url)
|
||||||
if not token:
|
if not parsed:
|
||||||
errors[CONF_ICLOUD_URL] = "invalid_icloud_url"
|
errors[CONF_ICLOUD_URL] = "invalid_icloud_url"
|
||||||
else:
|
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:
|
try:
|
||||||
await client.async_validate()
|
await client.async_validate()
|
||||||
except Exception: # noqa: BLE001 - any failure means bad/expired link
|
except Exception: # noqa: BLE001 - any failure means bad/expired link
|
||||||
errors["base"] = "icloud_cannot_connect"
|
errors["base"] = "icloud_cannot_connect"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(
|
await self.async_set_unique_id(
|
||||||
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}"
|
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}:{name}"
|
||||||
)
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
@@ -678,6 +683,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
data={
|
data={
|
||||||
CONF_PROVIDER: PROVIDER_ICLOUD,
|
CONF_PROVIDER: PROVIDER_ICLOUD,
|
||||||
CONF_ICLOUD_TOKEN: token,
|
CONF_ICLOUD_TOKEN: token,
|
||||||
|
CONF_ICLOUD_BACKEND: backend,
|
||||||
CONF_ICLOUD_IMAGE_SIZE: size,
|
CONF_ICLOUD_IMAGE_SIZE: size,
|
||||||
CONF_ALBUM_NAME: name,
|
CONF_ALBUM_NAME: name,
|
||||||
},
|
},
|
||||||
@@ -725,26 +731,35 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await client.async_login(otp_code=otp or None)
|
await client.async_login(otp_code=otp or None)
|
||||||
# Albums and category browsing live only in the Personal space
|
|
||||||
# (there is no Shared Space album/category API). For the Shared
|
|
||||||
# Space, validate access up front so a permission problem
|
|
||||||
# surfaces here, not later.
|
|
||||||
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 syn_api.SynologyOtpRequired:
|
except syn_api.SynologyOtpRequired:
|
||||||
errors["otp_code"] = "synology_otp_required"
|
errors["otp_code"] = "synology_otp_required"
|
||||||
except syn_api.SynologyPermissionError:
|
|
||||||
errors["base"] = "synology_shared_unavailable"
|
|
||||||
except Exception: # noqa: BLE001 - any failure means bad URL/creds
|
except Exception: # noqa: BLE001 - any failure means bad URL/creds
|
||||||
errors["base"] = "synology_cannot_connect"
|
errors["base"] = "synology_cannot_connect"
|
||||||
else:
|
|
||||||
|
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_url = client.base_url
|
||||||
self._syn_username = username
|
self._syn_username = username
|
||||||
self._syn_password = password
|
self._syn_password = password
|
||||||
@@ -857,7 +872,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
sel_id = json.dumps(selection, sort_keys=True)
|
sel_id = json.dumps(selection, sort_keys=True)
|
||||||
unique = (
|
unique = (
|
||||||
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
||||||
f"{self._syn_space}:{sel_id}"
|
f"{self._syn_space}:{sel_id}:{name}"
|
||||||
)
|
)
|
||||||
await self.async_set_unique_id(unique)
|
await self.async_set_unique_id(unique)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
@@ -948,7 +963,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(
|
await self.async_set_unique_id(
|
||||||
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
|
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
|
||||||
f"{username}:{client.folder}"
|
f"{username}:{client.folder}:{name}"
|
||||||
)
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ CONF_ICLOUD_URL = "icloud_url"
|
|||||||
CONF_ICLOUD_TOKEN = "icloud_token"
|
CONF_ICLOUD_TOKEN = "icloud_token"
|
||||||
CONF_ICLOUD_IMAGE_SIZE = "icloud_image_size"
|
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,
|
# ``full`` picks the largest derivative Apple generated (best for a slideshow,
|
||||||
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
|
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
|
||||||
ICLOUD_IMAGE_FULL = "full"
|
ICLOUD_IMAGE_FULL = "full"
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ from .const import (
|
|||||||
CONF_ICLOUD_TOKEN,
|
CONF_ICLOUD_TOKEN,
|
||||||
CONF_ICLOUD_IMAGE_SIZE,
|
CONF_ICLOUD_IMAGE_SIZE,
|
||||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||||
|
CONF_ICLOUD_BACKEND,
|
||||||
|
DEFAULT_ICLOUD_BACKEND,
|
||||||
|
ICLOUD_BACKEND_CLOUDKIT,
|
||||||
CONF_SYNOLOGY_URL,
|
CONF_SYNOLOGY_URL,
|
||||||
CONF_SYNOLOGY_USERNAME,
|
CONF_SYNOLOGY_USERNAME,
|
||||||
CONF_SYNOLOGY_PASSWORD,
|
CONF_SYNOLOGY_PASSWORD,
|
||||||
@@ -1523,18 +1526,23 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
async def _update_icloud(self) -> dict[str, Any]:
|
async def _update_icloud(self) -> dict[str, Any]:
|
||||||
"""Fetch photos from a public iCloud Shared Album.
|
"""Fetch photos from a public iCloud Shared Album.
|
||||||
|
|
||||||
The webstream response carries capture date and caption inline, so
|
Two public backends are supported: the legacy "shared streams" API and
|
||||||
there is no enrichment pass. Signed image URLs are resolved up front
|
the newer CloudKit backend used by iOS 26/macOS 26 share links. Both
|
||||||
and expire after roughly a day, so they are refreshed on every album
|
carry capture date and caption inline, so there is no enrichment pass.
|
||||||
refresh (like Google Photos).
|
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
|
from . import icloud as icloud_api
|
||||||
|
|
||||||
token = self.entry.data.get(CONF_ICLOUD_TOKEN)
|
token = self.entry.data.get(CONF_ICLOUD_TOKEN)
|
||||||
size = self.entry.data.get(CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE)
|
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:
|
if not token:
|
||||||
raise UpdateFailed("iCloud provider is missing the album 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)
|
client = icloud_api.IcloudClient(self.hass, token)
|
||||||
try:
|
try:
|
||||||
photos = await client.async_get_photos()
|
photos = await client.async_get_photos()
|
||||||
@@ -1581,6 +1589,43 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
"items": items,
|
"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]:
|
async def _update_synology(self) -> dict[str, Any]:
|
||||||
"""Fetch photos from a Synology Photos library via its web API.
|
"""Fetch photos from a Synology Photos library via its web API.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""iCloud Shared Album client and pure parsing helpers.
|
"""iCloud Shared Album client and pure parsing helpers.
|
||||||
|
|
||||||
Talks to Apple's public "shared streams" web API for a shared photo album -
|
Reads a public iCloud shared photo album. No account or password is involved;
|
||||||
the same undocumented JSON endpoints the iCloud web album viewer uses. No
|
the album's share token (from the share link) is the only credential. Apple
|
||||||
account or password is involved; the album's share token (the part after
|
serves shared albums through two different public backends depending on when
|
||||||
``#`` in the share link) is the only credential.
|
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:
|
- ``POST {base}/webstream`` ``{"streamCtag": null}`` -> ``{streamName, photos:
|
||||||
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
|
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
|
||||||
dateCreated, caption, width, height}]}``. May first answer with a
|
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
|
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.
|
expires after roughly a day, so it is refreshed on every album refresh.
|
||||||
|
|
||||||
Metadata: capture date (``dateCreated``) and caption are inline. Apple strips
|
CloudKit backend (``photos.icloud.com/shared/album/TOKEN``, iOS 26/macOS 26+):
|
||||||
GPS from shared-album web data, so there is no location.
|
- ``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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
|
||||||
import async_timeout
|
import async_timeout
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
@@ -34,6 +45,16 @@ _MAX_ASSETS = 20_000
|
|||||||
_URL_BATCH = 25
|
_URL_BATCH = 25
|
||||||
|
|
||||||
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
_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.
|
# Headers Apple's web endpoints expect for the shared-streams API.
|
||||||
_API_HEADERS = {
|
_API_HEADERS = {
|
||||||
@@ -42,6 +63,37 @@ _API_HEADERS = {
|
|||||||
"Accept": "application/json",
|
"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:
|
def _base62_to_int(value: str) -> int:
|
||||||
result = 0
|
result = 0
|
||||||
@@ -53,23 +105,52 @@ def _base62_to_int(value: str) -> int:
|
|||||||
def parse_share_link(url: str) -> str | None:
|
def parse_share_link(url: str) -> str | None:
|
||||||
"""Extract the album share token from a pasted iCloud link.
|
"""Extract the album share token from a pasted iCloud link.
|
||||||
|
|
||||||
Accepts a full ``https://www.icloud.com/sharedalbum/#TOKEN`` link or a
|
Accepts a legacy ``https://www.icloud.com/sharedalbum/#TOKEN`` link, a new
|
||||||
bare token. Returns ``None`` if nothing token-like is found.
|
``https://photos.icloud.com/shared/album/TOKEN`` link, or a bare token.
|
||||||
|
Returns ``None`` if nothing token-like is found.
|
||||||
"""
|
"""
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
text = url.strip()
|
text = url.strip()
|
||||||
|
# Legacy links carry the token in the fragment; new links carry it as the
|
||||||
|
# last path segment.
|
||||||
if "#" in text:
|
if "#" in text:
|
||||||
text = text.rsplit("#", 1)[1]
|
text = text.rsplit("#", 1)[1]
|
||||||
elif "/" in text:
|
elif "/" in text:
|
||||||
text = text.rstrip("/").rsplit("/", 1)[1]
|
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()
|
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 token
|
||||||
return None
|
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:
|
def partition_host(token: str) -> str:
|
||||||
"""Derive the shared-streams partition host for a token.
|
"""Derive the shared-streams partition host for a token.
|
||||||
|
|
||||||
@@ -250,3 +331,248 @@ class IcloudClient:
|
|||||||
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
||||||
payload = _json.loads(raw)
|
payload = _json.loads(raw)
|
||||||
return payload.get("streamName") if isinstance(payload, dict) else None
|
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",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||||
"requirements": ["Pillow"],
|
"requirements": ["Pillow"],
|
||||||
"version": "1.6.0"
|
"version": "1.7.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
},
|
},
|
||||||
"icloud": {
|
"icloud": {
|
||||||
"title": "iCloud Shared Album",
|
"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": {
|
"data": {
|
||||||
"album_name": "Album name",
|
"album_name": "Album name",
|
||||||
"icloud_url": "Shared album link",
|
"icloud_url": "Shared album link",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
},
|
},
|
||||||
"icloud": {
|
"icloud": {
|
||||||
"title": "iCloud Shared Album",
|
"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": {
|
"data": {
|
||||||
"album_name": "Album name",
|
"album_name": "Album name",
|
||||||
"icloud_url": "Shared album link",
|
"icloud_url": "Shared album link",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
* tap_action: none # none | more-info
|
* tap_action: none # none | more-info
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VERSION = "1.6.0";
|
const VERSION = "1.7.1";
|
||||||
|
|
||||||
const ANIMATED_TRANSITIONS = [
|
const ANIMATED_TRANSITIONS = [
|
||||||
"fade",
|
"fade",
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
"""C.A.F.E. - Visual automation editor for Home Assistant."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .panel import async_register_panel, async_unregister_panel
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
||||||
"""Set up the C.A.F.E. component."""
|
|
||||||
# This will be called when the integration is loaded
|
|
||||||
# But actual setup happens in async_setup_entry
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry) -> bool:
|
|
||||||
"""Set up C.A.F.E. from a config entry."""
|
|
||||||
|
|
||||||
# Register the panel (frontend)
|
|
||||||
await async_register_panel(hass)
|
|
||||||
|
|
||||||
_LOGGER.info("C.A.F.E. integration set up successfully")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
async_unregister_panel(hass)
|
|
||||||
return True
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,53 +0,0 @@
|
|||||||
"""Config flow for C.A.F.E. integration."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant import config_entries
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema({})
|
|
||||||
|
|
||||||
|
|
||||||
class CafeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for C.A.F.E."""
|
|
||||||
|
|
||||||
VERSION = 1
|
|
||||||
|
|
||||||
async def async_step_user(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> FlowResult:
|
|
||||||
"""Handle the initial step."""
|
|
||||||
if user_input is not None:
|
|
||||||
# Check if already configured
|
|
||||||
await self.async_set_unique_id(DOMAIN)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
|
|
||||||
return self.async_create_entry(
|
|
||||||
title="C.A.F.E.",
|
|
||||||
data={}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=STEP_USER_DATA_SCHEMA,
|
|
||||||
description_placeholders={
|
|
||||||
"name": "C.A.F.E.",
|
|
||||||
"description": "Visual automation editor for Home Assistant"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
|
|
||||||
"""Handle import from configuration.yaml."""
|
|
||||||
await self.async_set_unique_id(DOMAIN)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
|
|
||||||
return self.async_create_entry(title="C.A.F.E.", data={})
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Constants for C.A.F.E.."""
|
|
||||||
|
|
||||||
DOMAIN = "cafe"
|
|
||||||
PANEL_URL = "/cafe_panel"
|
|
||||||
PANEL_TITLE = "C.A.F.E."
|
|
||||||
PANEL_ICON = "mdi:coffee-to-go"
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "cafe",
|
"domain": "cafe",
|
||||||
"name": "C.A.F.E.",
|
"name": "C.A.F.E.",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"codeowners": [],
|
"codeowners": [],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["frontend", "http"],
|
"dependencies": ["frontend", "http"],
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Panel for C.A.F.E."""
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.components import frontend, panel_custom
|
|
||||||
from homeassistant.components.http import StaticPathConfig
|
|
||||||
|
|
||||||
from .const import DOMAIN, PANEL_TITLE, PANEL_ICON
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PANEL_URL = f"/api/{DOMAIN}_panel"
|
|
||||||
PANEL_NAME = f"{DOMAIN}-panel"
|
|
||||||
|
|
||||||
|
|
||||||
async def async_register_panel(hass: HomeAssistant) -> None:
|
|
||||||
"""Register the C.A.F.E. panel."""
|
|
||||||
# Get the path to the www folder within the component
|
|
||||||
www_path = Path(__file__).parent / "www"
|
|
||||||
|
|
||||||
# Register static path for the frontend assets
|
|
||||||
await hass.http.async_register_static_paths([
|
|
||||||
StaticPathConfig("/cafe-hass", str(www_path), False)
|
|
||||||
])
|
|
||||||
|
|
||||||
# The panel wrapper has a fixed name (not hashed)
|
|
||||||
wrapper_path = www_path / "assets" / "panel-wrapper.js"
|
|
||||||
if not wrapper_path.exists():
|
|
||||||
_LOGGER.error("panel-wrapper.js not found in assets directory")
|
|
||||||
_LOGGER.error(f"www path: {www_path}, assets exist: {(www_path / 'assets').exists()}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add cache-busting parameter to force fresh load
|
|
||||||
import time
|
|
||||||
cache_bust = int(time.time())
|
|
||||||
module_url = f"/cafe-hass/assets/panel-wrapper.js?v={cache_bust}"
|
|
||||||
|
|
||||||
# First try to unregister any existing panel
|
|
||||||
try:
|
|
||||||
hass.data.get("frontend_panels", {}).pop(DOMAIN, None)
|
|
||||||
_LOGGER.info("Removed any existing panel registration")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Register the panel as a custom panel with module_url
|
|
||||||
await panel_custom.async_register_panel(
|
|
||||||
hass,
|
|
||||||
webcomponent_name=PANEL_NAME,
|
|
||||||
frontend_url_path=DOMAIN,
|
|
||||||
module_url=module_url,
|
|
||||||
sidebar_title=PANEL_TITLE,
|
|
||||||
sidebar_icon=PANEL_ICON,
|
|
||||||
require_admin=True,
|
|
||||||
config={},
|
|
||||||
config_panel_domain=DOMAIN,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER.info("C.A.F.E. panel registered successfully with iframe wrapper")
|
|
||||||
|
|
||||||
|
|
||||||
def async_unregister_panel(hass: HomeAssistant) -> None:
|
|
||||||
"""Unregister the C.A.F.E. panel."""
|
|
||||||
frontend.async_remove_panel(hass, DOMAIN)
|
|
||||||
_LOGGER.info("C.A.F.E. panel unregistered")
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
|
|||||||
var d=Object.defineProperty;var o=(t,s,e)=>s in t?d(t,s,{enumerable:!0,configurable:!0,writable:!0,value:e}):t[s]=e;var a=(t,s,e)=>o(t,typeof s!="symbol"?s+"":s,e);class m extends HTMLElement{constructor(){super(...arguments);a(this,"_messageHandler");a(this,"iframe",null);a(this,"_hass")}set hass(e){var i;this._hass=e,window.hass=e,(i=this.iframe)!=null&&i.contentWindow&&this.iframe.contentWindow.setHass&&this.iframe.contentWindow.setHass(e)}get hass(){return this._hass}connectedCallback(){var n,r;this.style.display="block",this.style.width="100%",this.style.height="100%",this.style.position="relative";const i=((r=(n=this._hass)==null?void 0:n.themes)==null?void 0:r.darkMode)??!1?"hsl(222.2, 84%, 4.9%)":"hsl(0, 0%, 100%)";this.iframe=document.createElement("iframe"),this.iframe.src="/cafe-hass/index.html",this.iframe.style.width="100%",this.iframe.style.height="100%",this.iframe.style.border="none",this.iframe.style.display="block",this.iframe.style.background=i,this.iframe.setAttribute("allow","clipboard-read *; clipboard-write *"),this.appendChild(this.iframe),this._messageHandler=h=>{var l;h.source===((l=this.iframe)==null?void 0:l.contentWindow)&&h.data&&h.data.type==="CAFE_TOGGLE_SIDEBAR"&&this.dispatchEvent(new Event("hass-toggle-menu",{bubbles:!0,composed:!0}))},window.addEventListener("message",this._messageHandler)}disconnectedCallback(){this.iframe&&(this.removeChild(this.iframe),this.iframe=null),window.hass=void 0,this._messageHandler&&(window.removeEventListener("message",this._messageHandler),this._messageHandler=void 0)}}customElements.get("cafe-panel")||customElements.define("cafe-panel",m);
|
var d=Object.defineProperty;var o=(t,s,e)=>s in t?d(t,s,{enumerable:!0,configurable:!0,writable:!0,value:e}):t[s]=e;var h=(t,s,e)=>o(t,typeof s!="symbol"?s+"":s,e);class m extends HTMLElement{constructor(){super(...arguments);h(this,"_messageHandler");h(this,"iframe",null);h(this,"_hass")}set hass(e){var a;this._hass=e,window.hass=e;const i=(a=this.iframe)==null?void 0:a.contentWindow;i!=null&&i.setHass&&i.setHass(e)}get hass(){return this._hass}connectedCallback(){var a,n;this.style.display="block",this.style.width="100%",this.style.height="100%",this.style.position="relative";const i=((n=(a=this._hass)==null?void 0:a.themes)==null?void 0:n.darkMode)??!1?"hsl(222.2, 84%, 4.9%)":"hsl(0, 0%, 100%)";this.iframe=document.createElement("iframe"),this.iframe.src="/cafe-hass/index.html",this.iframe.style.width="100%",this.iframe.style.height="100%",this.iframe.style.border="none",this.iframe.style.display="block",this.iframe.style.background=i,this.iframe.setAttribute("allow","clipboard-read *; clipboard-write *"),this.appendChild(this.iframe),this._messageHandler=r=>{var l;r.source===((l=this.iframe)==null?void 0:l.contentWindow)&&r.data&&r.data.type==="CAFE_TOGGLE_SIDEBAR"&&this.dispatchEvent(new Event("hass-toggle-menu",{bubbles:!0,composed:!0}))},window.addEventListener("message",this._messageHandler)}disconnectedCallback(){this.iframe&&(this.removeChild(this.iframe),this.iframe=null),window.hass=void 0,this._messageHandler&&(window.removeEventListener("message",this._messageHandler),this._messageHandler=void 0)}}customElements.get("cafe-panel")||customElements.define("cafe-panel",m);
|
||||||
//# sourceMappingURL=panel-wrapper.js.map
|
//# sourceMappingURL=panel-wrapper.js.map
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"panel-wrapper.js","sources":["../../src/panel-wrapper.ts"],"sourcesContent":["/**\n * Minimal wrapper for Home Assistant panel integration.\n * This web component receives the hass object from HA, exposes it on window,\n * and loads the actual app in an iframe for proper document isolation.\n */\nimport type { HomeAssistant } from './types/hass';\n\n// Type for window with hass\ndeclare const window: Window & {\n hass?: HomeAssistant;\n};\n\nclass CafePanelWrapper extends HTMLElement {\n private _messageHandler?: (event: MessageEvent) => void;\n private iframe: HTMLIFrameElement | null = null;\n private _hass: HomeAssistant | undefined = undefined;\n\n // Properties that HA will set\n set hass(value: HomeAssistant | undefined) {\n this._hass = value;\n // Expose hass on window so iframe can access via window.parent.hass\n window.hass = value;\n\n // Notify the iframe of the update if it has registered a listener\n if (this.iframe?.contentWindow && (this.iframe.contentWindow as any).setHass) {\n (this.iframe.contentWindow as any).setHass(value);\n }\n }\n\n get hass() {\n return this._hass;\n }\n\n connectedCallback() {\n // Style the wrapper to fill the container\n this.style.display = 'block';\n this.style.width = '100%';\n this.style.height = '100%';\n this.style.position = 'relative';\n\n // Detect dark mode from hass to set initial background and avoid white flash\n const isDarkMode = this._hass?.themes?.darkMode ?? false;\n // These match the CSS variables in index.css:\n // Light: --background: 0 0% 100% (white)\n // Dark: --background: 222.2 84% 4.9% (dark blue)\n const bgColor = isDarkMode ? 'hsl(222.2, 84%, 4.9%)' : 'hsl(0, 0%, 100%)';\n\n // Create iframe pointing to the app\n this.iframe = document.createElement('iframe');\n this.iframe.src = '/cafe-hass/index.html';\n this.iframe.style.width = '100%';\n this.iframe.style.height = '100%';\n this.iframe.style.border = 'none';\n this.iframe.style.display = 'block';\n this.iframe.style.background = bgColor;\n // Allow same-origin access\n this.iframe.setAttribute('allow', 'clipboard-read *; clipboard-write *');\n\n this.appendChild(this.iframe);\n\n // Listen for messages from the iframe to trigger sidebar toggle\n this._messageHandler = (event: MessageEvent) => {\n // Only accept messages from our iframe\n if (event.source !== this.iframe?.contentWindow) return;\n if (event.data && event.data.type === 'CAFE_TOGGLE_SIDEBAR') {\n this.dispatchEvent(new Event('hass-toggle-menu', { bubbles: true, composed: true }));\n }\n };\n window.addEventListener('message', this._messageHandler);\n }\n\n disconnectedCallback() {\n if (this.iframe) {\n this.removeChild(this.iframe);\n this.iframe = null;\n }\n // Clean up window properties\n window.hass = undefined;\n if (this._messageHandler) {\n window.removeEventListener('message', this._messageHandler);\n this._messageHandler = undefined;\n }\n }\n}\n\n// Register the custom element\nif (!customElements.get('cafe-panel')) {\n customElements.define('cafe-panel', CafePanelWrapper);\n}\n"],"names":["CafePanelWrapper","__publicField","value","_a","_b","bgColor","event"],"mappings":"oKAYA,MAAMA,UAAyB,WAAY,CAA3C,kCACUC,EAAA,wBACAA,EAAA,cAAmC,MACnCA,EAAA,cAGR,IAAI,KAAKC,EAAkC,CAN7C,IAAAC,EAOI,KAAK,MAAQD,EAEb,OAAO,KAAOA,GAGVC,EAAA,KAAK,SAAL,MAAAA,EAAa,eAAkB,KAAK,OAAO,cAAsB,SAClE,KAAK,OAAO,cAAsB,QAAQD,CAAK,CAEpD,CAEA,IAAI,MAAO,CACT,OAAO,KAAK,KACd,CAEA,mBAAoB,CArBtB,IAAAC,EAAAC,EAuBI,KAAK,MAAM,QAAU,QACrB,KAAK,MAAM,MAAQ,OACnB,KAAK,MAAM,OAAS,OACpB,KAAK,MAAM,SAAW,WAOtB,MAAMC,IAJaD,GAAAD,EAAA,KAAK,QAAL,YAAAA,EAAY,SAAZ,YAAAC,EAAoB,WAAY,GAItB,wBAA0B,mBAGvD,KAAK,OAAS,SAAS,cAAc,QAAQ,EAC7C,KAAK,OAAO,IAAM,wBAClB,KAAK,OAAO,MAAM,MAAQ,OAC1B,KAAK,OAAO,MAAM,OAAS,OAC3B,KAAK,OAAO,MAAM,OAAS,OAC3B,KAAK,OAAO,MAAM,QAAU,QAC5B,KAAK,OAAO,MAAM,WAAaC,EAE/B,KAAK,OAAO,aAAa,QAAS,qCAAqC,EAEvE,KAAK,YAAY,KAAK,MAAM,EAG5B,KAAK,gBAAmBC,GAAwB,CAjDpD,IAAAH,EAmDUG,EAAM,WAAWH,EAAA,KAAK,SAAL,YAAAA,EAAa,gBAC9BG,EAAM,MAAQA,EAAM,KAAK,OAAS,uBACpC,KAAK,cAAc,IAAI,MAAM,mBAAoB,CAAE,QAAS,GAAM,SAAU,EAAA,CAAM,CAAC,CAEvF,EACA,OAAO,iBAAiB,UAAW,KAAK,eAAe,CACzD,CAEA,sBAAuB,CACjB,KAAK,SACP,KAAK,YAAY,KAAK,MAAM,EAC5B,KAAK,OAAS,MAGhB,OAAO,KAAO,OACV,KAAK,kBACP,OAAO,oBAAoB,UAAW,KAAK,eAAe,EAC1D,KAAK,gBAAkB,OAE3B,CACF,CAGK,eAAe,IAAI,YAAY,GAClC,eAAe,OAAO,aAAcN,CAAgB"}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>C.A.F.E. - Home Assistant</title>
|
|
||||||
<script>
|
|
||||||
// Set background color immediately to avoid white flash in dark mode
|
|
||||||
// This runs before CSS loads, checking parent window's hass dark mode setting
|
|
||||||
(function() {
|
|
||||||
try {
|
|
||||||
var isDark = window.parent && window.parent.hass && window.parent.hass.themes && window.parent.hass.themes.darkMode;
|
|
||||||
// Match CSS variables: light = hsl(0, 0%, 100%), dark = hsl(222.2, 84%, 4.9%)
|
|
||||||
var bg = isDark ? 'hsl(222.2, 84%, 4.9%)' : 'hsl(0, 0%, 100%)';
|
|
||||||
document.documentElement.style.background = bg;
|
|
||||||
// Inject style for body since it doesn't exist yet
|
|
||||||
var style = document.createElement('style');
|
|
||||||
style.textContent = 'body { background: ' + bg + ' !important; }';
|
|
||||||
document.head.appendChild(style);
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<script type="module" crossorigin src="/cafe-hass/assets/main-BIzscans.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/cafe-hass/assets/main-DfXhj31a.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"domain": "frigate",
|
||||||
|
"name": "Frigate",
|
||||||
|
"codeowners": [
|
||||||
|
"@blakeblackshear",
|
||||||
|
"@dermotduffy",
|
||||||
|
"@NickM-27"
|
||||||
|
],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": [
|
||||||
|
"http",
|
||||||
|
"media_source",
|
||||||
|
"mqtt"
|
||||||
|
],
|
||||||
|
"documentation": "https://github.com/blakeblackshear/frigate",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues",
|
||||||
|
"requirements": ["hass-web-proxy-lib==0.0.8","titlecase==2.4.1"],
|
||||||
|
"version": "5.15.4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
export_recording:
|
||||||
|
name: Export recording
|
||||||
|
description: Export a custom recording or timelapse.
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
playback_factor:
|
||||||
|
name: Playback Factor
|
||||||
|
description: Playback factor for recordings
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: realtime
|
||||||
|
default: realtime
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- "realtime"
|
||||||
|
- "timelapse_25x"
|
||||||
|
start_time:
|
||||||
|
name: Export Start Time
|
||||||
|
description: Start time of exported recording
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
end_time:
|
||||||
|
name: Export End Time
|
||||||
|
description: End time of exported recording
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
name:
|
||||||
|
name: Name
|
||||||
|
description: >
|
||||||
|
Optional name for the exported recording. If not provided, the API will
|
||||||
|
generate one.
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
favorite_event:
|
||||||
|
name: Favorite or unfavorite Event
|
||||||
|
description: >
|
||||||
|
Favorites or unfavorites an event. Favorited events are retained
|
||||||
|
indefinitely.
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
event_id:
|
||||||
|
name: Event ID
|
||||||
|
description: ID of the event to favorite or unfavorite.
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: "1656510950.19548-ihtjj7"
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
favorite:
|
||||||
|
name: Favorite
|
||||||
|
description: >
|
||||||
|
If the event should be favorited or unfavorited. Enable to favorite,
|
||||||
|
disable to unfavorite.
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: true
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
ptz:
|
||||||
|
name: Control camera via PTZ
|
||||||
|
description: >
|
||||||
|
Pan / Tilt, Zoom, or move a camera to a preset
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
action:
|
||||||
|
name: PTZ Service
|
||||||
|
description: Type of PTZ action
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: move
|
||||||
|
default: move
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- "move"
|
||||||
|
- "preset"
|
||||||
|
- "stop"
|
||||||
|
- "zoom"
|
||||||
|
argument:
|
||||||
|
name: PTZ Action
|
||||||
|
description: >
|
||||||
|
left, right, up, down for move; in, out for zoom; name of preset
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: down
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
create_event:
|
||||||
|
name: Create an event
|
||||||
|
description: >
|
||||||
|
Create a manual event with a given label for a camera.
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
label:
|
||||||
|
name: Label
|
||||||
|
description: Label for the event
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: "Doorbell press"
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
sub_label:
|
||||||
|
name: Sub Label
|
||||||
|
description: Sub label for the event
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: "Front door"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
duration:
|
||||||
|
name: Duration
|
||||||
|
description: >
|
||||||
|
Predetermined length of event.
|
||||||
|
Default is 30 seconds. Use 0 for indefinite.
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: 30
|
||||||
|
default: 30
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 0
|
||||||
|
max: 300
|
||||||
|
step: 1
|
||||||
|
include_recording:
|
||||||
|
name: Include Recording
|
||||||
|
description: >
|
||||||
|
Whether the event should save recordings along
|
||||||
|
with the snapshot that is taken.
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: true
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
end_event:
|
||||||
|
name: End an event
|
||||||
|
description: >
|
||||||
|
End a manual event with a given id for a camera.
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
event_id:
|
||||||
|
name: Event ID
|
||||||
|
description: ID of the event to end.
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: "1656510950.19548-ihtjj7"
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
review_summarize:
|
||||||
|
name: Review Summarize
|
||||||
|
description: >
|
||||||
|
Get a summary of review items for a specified time period.
|
||||||
|
Only available in Frigate 0.17+.
|
||||||
|
fields:
|
||||||
|
start_time:
|
||||||
|
name: Start Time
|
||||||
|
description: Start time for the review period
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
end_time:
|
||||||
|
name: End Time
|
||||||
|
description: End time for the review period
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "L'URL que utilitzeu per accedir a Frigate (p. ex. 'http://frigate:5000/')\\n\\nSi feu servir HassOS amb el complement, l'URL hauria de ser 'http://ccab4aaf-frigate:5000/' \\n\\nHome Assistant necessita accedir al port 5000 (api) i 8554/8555 (rtsp, webrtc) per a totes les funcions.\\n\\nLa integració configurarà sensors, càmeres i la funcionalitat del navegador multimèdia.\\n\\nSensors:\\n- Estadístiques per supervisar el rendiment de Frigate\\n- Recompte d'objectes per a totes les zones i càmeres\\n\\nCàmeres:\\n- Càmeres per a la imatge de l'últim objecte detectat per a cada càmera\\n- Entitats de càmera amb suport de transmissió\\n\\nNavegador multimèdia:\\n - Interfície d'usuari enriquida amb miniatures per explorar clips d'esdeveniments\\n- Interfície d'usuari enriquida per navegar per enregistraments les 24 hores al dia, els set dies a la setmana, per mes, dia, càmera, hora\\n\\nAPI:\\n- API de notificació amb punts de connexió públics per a imatges a les notificacions.",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "No s'ha pogut connectar",
|
||||||
|
"invalid_url": "URL no vàlid"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "El dispositiu ja està configurat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"rtsp_url_template": "Plantilla de l'URL del RTSP (vegeu la documentació)",
|
||||||
|
"media_browser_enable": "Habiliteu el navegador multimèdia",
|
||||||
|
"notification_proxy_enable": "Habiliteu el servidor intermediari no autenticat d'esdeveniments de notificacions",
|
||||||
|
"notification_proxy_expire_after_seconds": "No permetre l'accés a notificacions no autenticades després dels segons especificats (0=mai)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "El mode avançat està desactivat i només hi ha opcions avançades"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL, die Sie für den Zugriff auf Frigate verwenden (z. B. \"http://frigate:5000/\")\n\nWenn Sie HassOS mit dem Addon verwenden, sollte die URL „http://ccab4aaf-frigate:5000/“ lauten\n\nHome Assistant benötigt für alle Funktionen Zugriff auf Port 5000 (api) und 8554/8555 (rtsp, webrtc).\n\nDie Integration richtet Sensoren, Kameras und Medienbrowser-Funktionen ein.\n\nSensoren:\n- Statistiken zur Überwachung der Frigate-Leistung\n- Objektzählungen für alle Zonen und Kameras\n\nKameras:\n- Kameras für Bild des zuletzt erkannten Objekts für jede Kamera\n- Kameraeinheiten mit Stream-Unterstützung\n\nMedienbrowser:\n- Umfangreiche Benutzeroberfläche mit Vorschaubildern zum Durchsuchen von Event-Clips\n- Umfangreiche Benutzeroberfläche zum Durchsuchen von 24/7-Aufzeichnungen nach Monat, Tag, Kamera und Uhrzeit\n\nAPI:\n- Benachrichtigungs-API mit öffentlich zugänglichen Endpunkten für Bilder in Benachrichtigungen",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Verbindung fehlgeschlagen",
|
||||||
|
"invalid_url": "Ungültige URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Gerät ist bereits konfiguriert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"rtsp_url_template": "RTSP-URL-Vorlage (siehe Dokumentation)",
|
||||||
|
"media_browser_enable": "Aktivieren Sie den Medienbrowser",
|
||||||
|
"notification_proxy_enable": "Aktivieren Sie den Proxy für nicht authentifizierte Benachrichtigungsereignisse",
|
||||||
|
"notification_proxy_expire_after_seconds": "Zugriff auf nicht authentifizierte Benachrichtigungen nach Sekunden verbieten (0=nie)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Der erweiterte Modus ist deaktiviert und es stehen nur erweiterte Optionen zur Verfügung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL you use to access Frigate (ie. `http://frigate:5000/`)\n\nIf you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000/`\n\nHome Assistant needs access to port 5000 (api) and 8554/8555 (rtsp, webrtc) for all features.\n\nThe integration will setup sensors, cameras, and media browser functionality.\n\nSensors:\n- Stats to monitor frigate performance\n- Object counts for all zones and cameras\n\nCameras:\n- Cameras for image of the last detected object for each camera\n- Camera entities with stream support\n\nMedia Browser:\n- Rich UI with thumbnails for browsing event clips\n- Rich UI for browsing 24/7 recordings by month, day, camera, time\n\nAPI:\n- Notification API with public facing endpoints for images in notifications",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "Validate SSL",
|
||||||
|
"username": "Username (optional)",
|
||||||
|
"password": "Password (optional)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_url": "Invalid URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "Use Frigate-native WebRTC support",
|
||||||
|
"rtsp_url_template": "RTSP URL template (see documentation)",
|
||||||
|
"media_browser_enable": "Enable the media browser",
|
||||||
|
"notification_proxy_enable": "Enable the unauthenticated notification event proxy",
|
||||||
|
"notification_proxy_expire_after_seconds": "Disallow unauthenticated notification access after seconds (0=never)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Advanced mode is disabled and there are only advanced options"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL que vous utilisez pour accéder à Frigate (par exemple, `http://frigate:5000/`)\n\nSi vous utilisez HassOS avec l'addon, l'URL devrait être `http://ccab4aaf-frigate:5000/`\n\nHome Assistant a besoin d'accès au port 5000 (api) et 8554/8555 (rtsp, webrtc) pour toutes les fonctionnalités.\n\nL'intégration configurera des capteurs, des caméras et la fonctionnalité de navigateur multimédia.\n\nCapteurs :\n- Statistiques pour surveiller la performance de Frigate\n- Comptes d'objets pour toutes les zones et caméras\n\nCaméras :\n- Caméras pour l'image du dernier objet détecté pour chaque caméra\n- Entités de caméra avec support de flux\n\nNavigateur multimédia :\n- Interface riche avec miniatures pour parcourir les clips d'événements\n- Interface riche pour parcourir les enregistrements 24/7 par mois, jour, caméra, heure\n\nAPI :\n- API de notification avec des points de terminaison publics pour les images dans les notifications",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "Valider SSL",
|
||||||
|
"username": "Nom d'utilisateur (facultatif)",
|
||||||
|
"password": "Mot de passe (facultatif)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Échec de la connexion",
|
||||||
|
"invalid_url": "URL invalide"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "L'appareil est déjà configuré"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "Utiliser le support WebRTC natif de Frigate",
|
||||||
|
"rtsp_url_template": "Modèle d'URL RTSP (voir la documentation)",
|
||||||
|
"media_browser_enable": "Activer le navigateur multimédia",
|
||||||
|
"notification_proxy_enable": "Activer le proxy d'événement de notification non authentifié",
|
||||||
|
"notification_proxy_expire_after_seconds": "Interdire l'accès à la notification non authentifiée après secondes (0=jamais)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Le mode avancé est désactivé et il n'y a que des options avancées"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 8554/8555 (rtsp, webrtc) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Falhou ao conectar",
|
||||||
|
"invalid_url": "URL inválida"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "O dispositivo já está configurado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"rtsp_url_template": "Modelo de URL RTSP (consulte a documentação)",
|
||||||
|
"notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado",
|
||||||
|
"notification_proxy_expire_after_seconds": "Proibir acesso de notificação não autenticado após segundos (0=nunca)",
|
||||||
|
"media_browser_enable": "Ative o navegador de mídia"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL que usa para aceder ao Frigate (ou seja, `http://frigate:5000/`)\n\nSe estiver usar HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 8554/8555 (rtsp, webrtc) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Falhou a ligar",
|
||||||
|
"invalid_url": "Link inválido"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "O dispositivo já está configurado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"notification_proxy_enable": "Activar o proxy de evento de notificação não autenticado"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL, который вы используете для доступа к Frigate (например, http://frigate:5000/)\n\nЕсли вы используете HassOS с дополнением, URL должен быть http://ccab4aaf-frigate:5000/\n\nHome Assistant требуется доступ к порту 5000 (API) и 8554/8555 (RTSP, WEBRTC) для всех функций.\n\n Интеграция настроит сенсоры, камеры и функциональность медиа-браузера.\n\nСенсоры:\n- Статистика для отслеживания производительности Frigate\n- Количество объектов для всех зон и камер\n\nКамеры:\n- Камеры для снимка последнего обнаруженного объекта с каждой камеры\n- Камеры с поддержкой потока\n\nМедиа-браузер:\n- Пользовательский интерфейс с миниатюрами для просмотра записей 24/7 по времени и камерам\n\nAPI:\n- API для отправки событий во внешние системы",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Не удалось подключиться",
|
||||||
|
"invalid_url": "Неверный URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Устройство уже настроено"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"rtsp_url_template": "Шаблон URL для RTSP (см. документацию)",
|
||||||
|
"media_browser_enable": "Включить медиа-браузер",
|
||||||
|
"notification_proxy_enable": "Включить незащищённый прокси-сервер уведомлений",
|
||||||
|
"notification_proxy_expire_after_seconds": "Запретить неаутентифицированный доступ к уведомлениям после N секунд (0=никогда)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Режим расширенных настроек отключен; доступны только основные параметры"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL du använder för att komma åt Frigate (t.ex. `http://frigate:5000/`)\n\nOm du använder HassOS med tillägget ska URL:en vara `http://ccab4aaf-frigate:5000/`\n\nHome Assistant behöver åtkomst till port 5000 (api) och 8554/8555 (rtsp, webrtc) för alla funktioner.\n\nIntegrationen kommer att konfigurera sensorer, kameror och medialäsarfunktioner.\n\nSensorer:\n- Statistik för att övervaka frigates prestanda\n- Objekträkning för alla zoner och kameror\n\nKameror:\n- Kameror för bild av det senast detekterade objektet för varje kamera\n- Kameraenheter med strömningsstöd\n\nMedialäsare:\n- Rikt användargränssnitt med miniatyrbilder för att bläddra bland händelseklipp\n- Rikt användargränssnitt för att bläddra bland inspelningar dygnet runt efter månad, dag, kamera, tid\n\nAPI:\n- Aviserings-API med offentliga slutpunkter för bilder i aviseringar",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "Validera SSL",
|
||||||
|
"username": "Användarnamn (valfritt)",
|
||||||
|
"password": "Lösenord (valfritt)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Misslyckades med att ansluta",
|
||||||
|
"invalid_url": "Ogiltig URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Enheten är redan konfigurerad"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "Använd Frigate-nativt WebRTC-stöd",
|
||||||
|
"rtsp_url_template": "RTSP URL-mall (se dokumentation)",
|
||||||
|
"media_browser_enable": "Aktivera medieläsaren",
|
||||||
|
"notification_proxy_enable": "Aktivera proxy för oautentiserade aviseringshändelser",
|
||||||
|
"notification_proxy_expire_after_seconds": "Tillåt inte åtkomst till oautentiserade aviseringar efter sekunder (0=aldrig)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Avancerat läge är inaktiverat och det finns bara avancerade alternativ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Frigate'e erişmek için kullandığınız URL (örneğin `http://frigate:5000/`)\n\nHassOS ile eklentiyi kullanıyorsanız, URL `http://ccab4aaf-frigate:5000/` olmalıdır\n\nHome Assistant'ın tüm özellikleri kullanabilmesi için 5000 (api) ve 8554/8555 (rtsp, webrtc) portlarına erişimi olmalıdır.\n\nEntegrasyon sensörler, kameralar ve medya tarayıcısı işlevselliğini kuracaktır.\n\nSensörler:\n- Frigate performansını izlemek için istatistikler\n- Tüm bölgeler ve kameralar için nesne sayıları\n\nKameralar:\n- Her kamera için son Algılanan nesnenin görüntüsünü gösteren kameralar\n- Yayın destekli kamera varlıkları\n\nMedya Tarayıcısı:\n- Olay kliplerini göz atmak için küçük resimli zengin arayüz\n- Ay, gün, kamera ve zamana göre 7/24 kayıtları göz atmak için zengin arayüz\n\nAPI:\n- Bildirimlerde görüntüler için herkese açık uçlara sahip bildirim API'si",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "SSL Doğrula",
|
||||||
|
"username": "Kullanıcı Adı (isteğe bağlı)",
|
||||||
|
"password": "Şifre (isteğe bağlı)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Bağlantı kurulamadi",
|
||||||
|
"invalid_url": "Geçersiz URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Cihaz zaten yapılandırılmış"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "Frigate'in yerel WebRTC desteğini kullan",
|
||||||
|
"rtsp_url_template": "RTSP URL şablonu (dokümantasyona bakınız)",
|
||||||
|
"media_browser_enable": "Medya tarayıcısını etkinleştir",
|
||||||
|
"notification_proxy_enable": "Kimlik dogrulamasi gerektirmeyen bildirim olay proxy'sini etkinlestir",
|
||||||
|
"notification_proxy_expire_after_seconds": "Kimlik doğrulaması gerektirmeyen bildirim erişimini belirtilen saniye sonra devre dışı bırak (0=asla)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Gelişmiş mod devre dışı ve sadece gelişmiş seçenekler mevcut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "您用于访问 Frigate 的 URL(例如:`http://frigate:5000/`)\n\n如果您正在使用带有插件的 HassOS,URL 应该是 `http://ccab4aaf-frigate:5000/`\n\nHome Assistant 需要访问端口 5000(API)和 8554/8555(RTSP,WebRTC)才能使用所有功能。\n\n该集成将设置传感器、摄像头和媒体浏览器功能。\n\n传感器:\n- 监控 Frigate 性能的统计数据\n- 所有区域和摄像头的对象计数\n\n摄像头:\n- 每个摄像头最后检测到的对象图像的摄像头\n- 支持流的摄像头实体\n\n媒体浏览器:\n- 带有缩略图的丰富用户界面,用于浏览事件剪辑\n- 丰富的用户界面,用于按月、日、摄像头、时间浏览全天候录像\n\nAPI:\n- 通知 API,具有用于通知中图像的公共接口",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "验证 SSL",
|
||||||
|
"username": "用户名(可选)",
|
||||||
|
"password": "密码(可选)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "连接失败",
|
||||||
|
"invalid_url": "无效的 URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "设备已配置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "使用 Frigate 原生 WebRTC 支持",
|
||||||
|
"rtsp_url_template": "RTSP URL 模板(参见文档)",
|
||||||
|
"media_browser_enable": "启用媒体浏览器",
|
||||||
|
"notification_proxy_enable": "启用未经身份验证的通知事件代理",
|
||||||
|
"notification_proxy_expire_after_seconds": "在指定秒数后禁止未经身份验证的通知访问(0=永不)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "高级模式已禁用,且只有高级选项可用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ from .const import (
|
|||||||
TOOLS_ENTRY_LEGACY_TITLE,
|
TOOLS_ENTRY_LEGACY_TITLE,
|
||||||
TOOLS_ENTRY_TITLE,
|
TOOLS_ENTRY_TITLE,
|
||||||
YAML_KEY_DEFAULT_POST_ACTION,
|
YAML_KEY_DEFAULT_POST_ACTION,
|
||||||
|
YAML_KEY_DENYLIST,
|
||||||
YAML_KEY_POST_ACTIONS,
|
YAML_KEY_POST_ACTIONS,
|
||||||
)
|
)
|
||||||
from .websocket_api import async_register_commands
|
from .websocket_api import async_register_commands
|
||||||
@@ -80,6 +81,8 @@ SERVICE_EDIT_YAML_CONFIG = "edit_yaml_config"
|
|||||||
SERVICE_GET_CALLER_TOKEN = "get_caller_token"
|
SERVICE_GET_CALLER_TOKEN = "get_caller_token"
|
||||||
SERVICE_GET_ALLOWED_PATHS = "get_allowed_paths"
|
SERVICE_GET_ALLOWED_PATHS = "get_allowed_paths"
|
||||||
SERVICE_SET_ALLOWED_PATHS = "set_allowed_paths"
|
SERVICE_SET_ALLOWED_PATHS = "set_allowed_paths"
|
||||||
|
SERVICE_GET_EXTRA_YAML_KEYS = "get_extra_yaml_keys"
|
||||||
|
SERVICE_SET_EXTRA_YAML_KEYS = "set_extra_yaml_keys"
|
||||||
# Read-only access to pre-#1579 YAML backups in .ha_mcp_tools_backups/, so the
|
# Read-only access to pre-#1579 YAML backups in .ha_mcp_tools_backups/, so the
|
||||||
# shared edits-backup interface can list/view/diff/restore them (#1579). These
|
# shared edits-backup interface can list/view/diff/restore them (#1579). These
|
||||||
# historical artifacts predate the fold into the shared store; new writes no
|
# historical artifacts predate the fold into the shared store; new writes no
|
||||||
@@ -105,6 +108,18 @@ _ALLOWED_PATHS_STORAGE_KEY = f"{DOMAIN}_allowed_paths"
|
|||||||
_ALLOWED_PATHS_STORAGE_VERSION = 1
|
_ALLOWED_PATHS_STORAGE_VERSION = 1
|
||||||
_HASS_DATA_ALLOWED_PATHS_KEY = "allowed_paths"
|
_HASS_DATA_ALLOWED_PATHS_KEY = "allowed_paths"
|
||||||
|
|
||||||
|
# User-configurable extra YAML write keys (#1887). Same store-per-concern
|
||||||
|
# rationale as the allowed paths above: a separate Store, loaded into hass.data
|
||||||
|
# at setup and updated in place by set_extra_yaml_keys so enforcement picks up
|
||||||
|
# changes with no HA restart. This is the component-owned half of the setting;
|
||||||
|
# the ha-mcp server also carries its own HA_MCP_EXTRA_YAML_KEYS, and the
|
||||||
|
# effective write allowlist is the union of the two (the server reads this store
|
||||||
|
# via get_extra_yaml_keys). YAML_KEY_DENYLIST members are stripped on the way in
|
||||||
|
# and re-checked at enforcement, so the store can never widen the deny floor.
|
||||||
|
_EXTRA_YAML_KEYS_STORAGE_KEY = f"{DOMAIN}_extra_yaml_keys"
|
||||||
|
_EXTRA_YAML_KEYS_STORAGE_VERSION = 1
|
||||||
|
_HASS_DATA_EXTRA_YAML_KEYS_KEY = "extra_yaml_keys"
|
||||||
|
|
||||||
# Service schemas
|
# Service schemas
|
||||||
SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
|
SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -130,6 +145,15 @@ SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("disabled_packages_keys", default=list): vol.All(
|
vol.Optional("disabled_packages_keys", default=list): vol.All(
|
||||||
cv.ensure_list, [cv.string]
|
cv.ensure_list, [cv.string]
|
||||||
),
|
),
|
||||||
|
# Caller-provided extra top-level keys the operator has opted into
|
||||||
|
# on top of ALLOWED_YAML_KEYS (#1887). Additive only: the handler
|
||||||
|
# filters YAML_KEY_DENYLIST out before use, so a caller cannot
|
||||||
|
# unlock a trust-boundary key by sending it here. Empty list (the
|
||||||
|
# default) means the built-in allowlist applies unchanged, which
|
||||||
|
# is also what a caller that predates this field produces.
|
||||||
|
vol.Optional("extra_allowed_keys", default=list): vol.All(
|
||||||
|
cv.ensure_list, [cv.string]
|
||||||
|
),
|
||||||
# Two-step preview/confirm flow (#1720). Both optional with
|
# Two-step preview/confirm flow (#1720). Both optional with
|
||||||
# old-behavior defaults so a pre-confirm-flow server (which never
|
# old-behavior defaults so a pre-confirm-flow server (which never
|
||||||
# sends them) still gets an immediate write.
|
# sends them) still gets an immediate write.
|
||||||
@@ -208,6 +232,24 @@ SERVICE_SET_ALLOWED_PATHS_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# get_extra_yaml_keys / set_extra_yaml_keys back the component's own options
|
||||||
|
# flow and the ha-mcp settings UI (#1887). Both are caller-token + admin gated,
|
||||||
|
# matching the allowed-paths pair. set_extra_yaml_keys receives the FULL
|
||||||
|
# replacement list; the handler strips whitespace/empties and drops any
|
||||||
|
# YAML_KEY_DENYLIST member, reporting drops in ``rejected``.
|
||||||
|
SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("keys", default=list): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
SERVICE_LIST_LEGACY_BACKUPS_SCHEMA = vol.Schema(
|
SERVICE_LIST_LEGACY_BACKUPS_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||||
@@ -335,6 +377,128 @@ async def _save_allowed_paths(hass: HomeAssistant, paths: list[str]) -> None:
|
|||||||
await store.async_save({"paths": paths})
|
await store.async_save({"paths": paths})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_extra_yaml_keys(raw: Any) -> tuple[list[str], list[Any]]:
|
||||||
|
"""Clean a candidate extra-YAML-keys list into ``(kept, dropped)`` (#1887).
|
||||||
|
|
||||||
|
Mirrors the server's ``parse_extra_yaml_write_keys`` (strip whitespace, drop
|
||||||
|
empties, dedup, sort) and additionally drops any ``YAML_KEY_DENYLIST``
|
||||||
|
member, so the component store can never widen the deny floor. Non-string
|
||||||
|
and blank entries are dropped too. ``dropped`` collects every rejected
|
||||||
|
entry for reporting/logging. Case is preserved: HA's own top-level domain
|
||||||
|
lookup is exact-case, so a mis-cased key never reaches a real integration.
|
||||||
|
"""
|
||||||
|
kept: list[str] = []
|
||||||
|
dropped: list[Any] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for entry in raw if isinstance(raw, list) else []:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
dropped.append(entry)
|
||||||
|
continue
|
||||||
|
key = entry.strip()
|
||||||
|
if not key or key in YAML_KEY_DENYLIST:
|
||||||
|
dropped.append(entry)
|
||||||
|
continue
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
kept.append(key)
|
||||||
|
return sorted(kept), dropped
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_extra_yaml_keys(hass: HomeAssistant) -> list[str]:
|
||||||
|
"""Return the persisted user-configurable extra YAML write keys (#1887).
|
||||||
|
|
||||||
|
Fail-safe like :func:`_load_allowed_paths`: a corrupt/unreadable or
|
||||||
|
hand-edited store never propagates out of ``async_setup_entry``. Every entry
|
||||||
|
is re-validated through :func:`_normalize_extra_yaml_keys`, so a denylisted
|
||||||
|
or malformed key can never load into ``hass.data`` (defense in depth - the
|
||||||
|
deny floor is also re-checked at enforcement). Empty list on first run or a
|
||||||
|
malformed store.
|
||||||
|
"""
|
||||||
|
store: Store = Store(
|
||||||
|
hass, _EXTRA_YAML_KEYS_STORAGE_VERSION, _EXTRA_YAML_KEYS_STORAGE_KEY
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = await store.async_load()
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ha_mcp_tools: could not load the extra-YAML-keys store; ignoring "
|
||||||
|
"it and granting no extra write keys.",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
raw = data.get("keys")
|
||||||
|
if raw is not None and not isinstance(raw, list):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ha_mcp_tools extra-YAML-keys store is malformed (keys is %s, "
|
||||||
|
"expected list); ignoring it.",
|
||||||
|
type(raw).__name__,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
kept, dropped = _normalize_extra_yaml_keys(raw)
|
||||||
|
if dropped:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ha_mcp_tools: dropped %d invalid entr%s from the persisted "
|
||||||
|
"extra-YAML-keys store: %r",
|
||||||
|
len(dropped),
|
||||||
|
"y" if len(dropped) == 1 else "ies",
|
||||||
|
dropped,
|
||||||
|
)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_extra_yaml_keys(hass: HomeAssistant, keys: list[str]) -> None:
|
||||||
|
"""Persist the user-configurable extra YAML write keys to .storage."""
|
||||||
|
store: Store = Store(
|
||||||
|
hass, _EXTRA_YAML_KEYS_STORAGE_VERSION, _EXTRA_YAML_KEYS_STORAGE_KEY
|
||||||
|
)
|
||||||
|
await store.async_save({"keys": keys})
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_allowed_paths(
|
||||||
|
hass: HomeAssistant, raw_paths: Any
|
||||||
|
) -> tuple[list[str], list[Any]]:
|
||||||
|
"""Normalize, persist, and hot-swap the extra directories (#1567, #1887).
|
||||||
|
|
||||||
|
Shared by the ``set_allowed_paths`` service and the tools-entry options flow
|
||||||
|
so both edit the store through one validated path. Each entry runs through
|
||||||
|
:func:`_normalize_extra_dir`; traversal / out-of-config / deny-floor entries
|
||||||
|
are dropped into ``rejected``. Persists to .storage and updates hass.data so
|
||||||
|
enforcement applies live. Returns ``(kept, rejected)``.
|
||||||
|
"""
|
||||||
|
config_dir = Path(hass.config.config_dir)
|
||||||
|
normalized: list[str] = []
|
||||||
|
rejected: list[Any] = []
|
||||||
|
for entry in raw_paths if isinstance(raw_paths, list) else []:
|
||||||
|
norm = (
|
||||||
|
_normalize_extra_dir(entry, config_dir) if isinstance(entry, str) else None
|
||||||
|
)
|
||||||
|
if norm is None:
|
||||||
|
rejected.append(entry)
|
||||||
|
elif norm not in normalized:
|
||||||
|
normalized.append(norm)
|
||||||
|
await _save_allowed_paths(hass, normalized)
|
||||||
|
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
|
||||||
|
return normalized, rejected
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_extra_yaml_keys(
|
||||||
|
hass: HomeAssistant, raw_keys: Any
|
||||||
|
) -> tuple[list[str], list[Any]]:
|
||||||
|
"""Normalize, persist, and hot-swap the extra YAML write keys (#1887).
|
||||||
|
|
||||||
|
Shared by the ``set_extra_yaml_keys`` service and the tools-entry options
|
||||||
|
flow. Delegates validation to :func:`_normalize_extra_yaml_keys` (strip,
|
||||||
|
dedup, sort, drop denylist). Persists to .storage and updates hass.data so
|
||||||
|
enforcement applies live. Returns ``(kept, rejected)``.
|
||||||
|
"""
|
||||||
|
normalized, rejected = _normalize_extra_yaml_keys(raw_keys)
|
||||||
|
await _save_extra_yaml_keys(hass, normalized)
|
||||||
|
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_EXTRA_YAML_KEYS_KEY] = normalized
|
||||||
|
return normalized, rejected
|
||||||
|
|
||||||
|
|
||||||
def _unified_diff(before: str, after: str, rel_path: str, max_lines: int = 200) -> str:
|
def _unified_diff(before: str, after: str, rel_path: str, max_lines: int = 200) -> str:
|
||||||
"""Unified diff of a prospective write, capped for response size."""
|
"""Unified diff of a prospective write, capped for response size."""
|
||||||
lines = list(
|
lines = list(
|
||||||
@@ -2095,7 +2259,10 @@ def _build_edit_yaml_config_handler(
|
|||||||
|
|
||||||
# Parse and validate yaml_path (replaces the old ALLOWED_YAML_KEYS check)
|
# Parse and validate yaml_path (replaces the old ALLOWED_YAML_KEYS check)
|
||||||
kind, path_parts, path_err = _parse_and_validate_yaml_path(
|
kind, path_parts, path_err = _parse_and_validate_yaml_path(
|
||||||
yaml_path, is_package=is_package, is_theme=is_theme
|
yaml_path,
|
||||||
|
is_package=is_package,
|
||||||
|
is_theme=is_theme,
|
||||||
|
extra_allowed_keys=_effective_extra_allowed_keys(hass, call),
|
||||||
)
|
)
|
||||||
if path_err is not None:
|
if path_err is not None:
|
||||||
return {"success": False, "error": path_err}
|
return {"success": False, "error": path_err}
|
||||||
@@ -2218,18 +2385,59 @@ def _validate_lovelace_dashboard_path(
|
|||||||
return "lovelace_dashboard", parts, None
|
return "lovelace_dashboard", parts, None
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_extra_allowed_keys(call: ServiceCall) -> frozenset[str]:
|
||||||
|
"""Return the operator-configured extra write keys for this call (#1887).
|
||||||
|
|
||||||
|
``YAML_KEY_DENYLIST`` members are dropped here so they can never widen the
|
||||||
|
``allowed`` set that the generic "not in the allowed list" rejection lists
|
||||||
|
for some *other* invalid key. A direct write to a denied key does not rely
|
||||||
|
on this drop: ``_parse_and_validate_yaml_path`` checks the denylist first
|
||||||
|
and returns the categorical floor message before any allow-set is
|
||||||
|
consulted. Keys already covered by the built-in sets are harmless
|
||||||
|
duplicates and stay.
|
||||||
|
"""
|
||||||
|
return frozenset(
|
||||||
|
key
|
||||||
|
for key in call.data.get("extra_allowed_keys", [])
|
||||||
|
if key and key not in YAML_KEY_DENYLIST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_extra_allowed_keys(
|
||||||
|
hass: HomeAssistant, call: ServiceCall
|
||||||
|
) -> frozenset[str]:
|
||||||
|
"""Union the per-call wire keys with the component-stored keys (#1887).
|
||||||
|
|
||||||
|
The ha-mcp server passes its own ``HA_MCP_EXTRA_YAML_KEYS`` on the wire; the
|
||||||
|
component's own options flow / settings store contributes
|
||||||
|
:func:`_current_extra_yaml_keys`. Both sources are denylist-filtered - the
|
||||||
|
wire in :func:`_caller_extra_allowed_keys`, the store on save/load - and
|
||||||
|
:func:`_parse_and_validate_yaml_path` re-checks the deny floor regardless,
|
||||||
|
so the union can never lift a forbidden key. The store side is filtered
|
||||||
|
again here purely as defense in depth.
|
||||||
|
"""
|
||||||
|
stored = frozenset(
|
||||||
|
key for key in _current_extra_yaml_keys(hass) if key not in YAML_KEY_DENYLIST
|
||||||
|
)
|
||||||
|
return _caller_extra_allowed_keys(call) | stored
|
||||||
|
|
||||||
|
|
||||||
def _parse_and_validate_yaml_path(
|
def _parse_and_validate_yaml_path(
|
||||||
yaml_path: str,
|
yaml_path: str,
|
||||||
*,
|
*,
|
||||||
is_package: bool = False,
|
is_package: bool = False,
|
||||||
is_theme: bool = False,
|
is_theme: bool = False,
|
||||||
|
extra_allowed_keys: frozenset[str] = frozenset(),
|
||||||
) -> tuple[str, tuple[str, ...], str | None]:
|
) -> tuple[str, tuple[str, ...], str | None]:
|
||||||
"""Parse and validate a yaml_path argument.
|
"""Parse and validate a yaml_path argument.
|
||||||
|
|
||||||
Three accepted shapes:
|
Three accepted shapes:
|
||||||
1. Single segment in ALLOWED_YAML_KEYS -> kind='single'
|
1. Single segment in ALLOWED_YAML_KEYS -> kind='single'
|
||||||
When ``is_package=True``, single segments in PACKAGES_ONLY_YAML_KEYS
|
When ``is_package=True``, single segments in PACKAGES_ONLY_YAML_KEYS
|
||||||
(automation, script, scene) are also accepted.
|
(automation, script, scene) are also accepted. ``extra_allowed_keys``
|
||||||
|
adds operator-opted-in keys on top of ALLOWED_YAML_KEYS (#1887); it
|
||||||
|
overrides neither ``YAML_KEY_DENYLIST`` (filtered out before it gets
|
||||||
|
here) nor the packages-only restriction.
|
||||||
2. Exactly 'lovelace.dashboards.<url_path>' -> kind='lovelace_dashboard'
|
2. Exactly 'lovelace.dashboards.<url_path>' -> kind='lovelace_dashboard'
|
||||||
3. Single segment theme name (no dots) when ``is_theme=True`` -> kind='theme'
|
3. Single segment theme name (no dots) when ``is_theme=True`` -> kind='theme'
|
||||||
|
|
||||||
@@ -2256,13 +2464,41 @@ def _parse_and_validate_yaml_path(
|
|||||||
# Shape 1: single key
|
# Shape 1: single key
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
key = parts[0]
|
key = parts[0]
|
||||||
|
# The deny floor is checked before every single-key accept branch, so
|
||||||
|
# it holds even if a denied key is ever added to one of the allow
|
||||||
|
# sets. It deliberately does not cover the theme branch above: under
|
||||||
|
# ``is_theme`` the segment names a file in themes/, not a top-level
|
||||||
|
# configuration key, so the same word carries no trust-boundary
|
||||||
|
# meaning there.
|
||||||
|
if key in YAML_KEY_DENYLIST:
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
(),
|
||||||
|
(
|
||||||
|
f"Key '{yaml_path}' can never be edited through this "
|
||||||
|
"service: it redefines Home Assistant's own trust "
|
||||||
|
"boundary (authentication, proxy/CORS handling, or "
|
||||||
|
"frontend module loading). This floor cannot be lifted "
|
||||||
|
"by the extra-write-keys setting. Edit it by hand if "
|
||||||
|
"you really need to change it."
|
||||||
|
),
|
||||||
|
)
|
||||||
if key in ALLOWED_YAML_KEYS:
|
if key in ALLOWED_YAML_KEYS:
|
||||||
return "single", parts, None
|
return "single", parts, None
|
||||||
if is_package and key in PACKAGES_ONLY_YAML_KEYS:
|
if is_package and key in PACKAGES_ONLY_YAML_KEYS:
|
||||||
return "single", parts, None
|
return "single", parts, None
|
||||||
|
# Operator extra keys widen ALLOWED_YAML_KEYS only. They deliberately
|
||||||
|
# do NOT lift the packages-only restriction: those keys reach
|
||||||
|
# packages/*.yaml through the branch above (still governed by their
|
||||||
|
# per-key toggle) and stay rejected in configuration.yaml, so the
|
||||||
|
# storage-mode/YAML-mode collision guarantee holds however the
|
||||||
|
# operator fills the setting.
|
||||||
|
if key in extra_allowed_keys and key not in PACKAGES_ONLY_YAML_KEYS:
|
||||||
|
return "single", parts, None
|
||||||
# Reaching here means the key was not accepted. If it is a
|
# Reaching here means the key was not accepted. If it is a
|
||||||
# PACKAGES_ONLY key, we know is_package=False (otherwise the
|
# PACKAGES_ONLY key, we know is_package=False (the packages branch
|
||||||
# preceding branch would have returned) — emit the targeted
|
# would have returned, and the extra-keys branch excludes them
|
||||||
|
# precisely so this guidance still fires) – emit the targeted
|
||||||
# "move it to a package file" guidance instead of the generic
|
# "move it to a package file" guidance instead of the generic
|
||||||
# allowlist dump below.
|
# allowlist dump below.
|
||||||
if key in PACKAGES_ONLY_YAML_KEYS:
|
if key in PACKAGES_ONLY_YAML_KEYS:
|
||||||
@@ -2278,9 +2514,9 @@ def _parse_and_validate_yaml_path(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
allowed = (
|
allowed = (
|
||||||
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS
|
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS | extra_allowed_keys
|
||||||
if is_package
|
if is_package
|
||||||
else ALLOWED_YAML_KEYS
|
else ALLOWED_YAML_KEYS | extra_allowed_keys
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
"",
|
"",
|
||||||
@@ -2421,6 +2657,16 @@ def _current_extra_dirs(hass: HomeAssistant) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _current_extra_yaml_keys(hass: HomeAssistant) -> list[str]:
|
||||||
|
"""Return the live component-configured extra YAML write keys from hass.data."""
|
||||||
|
domain_data = hass.data.get(DOMAIN)
|
||||||
|
if isinstance(domain_data, dict):
|
||||||
|
keys = domain_data.get(_HASS_DATA_EXTRA_YAML_KEYS_KEY)
|
||||||
|
if isinstance(keys, list):
|
||||||
|
return keys
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _build_list_files_handler(
|
def _build_list_files_handler(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
@@ -2520,9 +2766,11 @@ async def _shape_read_file_response(
|
|||||||
# Apply special handling for specific files
|
# Apply special handling for specific files
|
||||||
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
|
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
|
||||||
|
|
||||||
# Mask secrets.yaml
|
# Mask secrets.yaml. Offloaded because the first make_yaml() call on a
|
||||||
|
# thread constructs a ruamel YAML instance, whose plugin discovery globs
|
||||||
|
# the site-packages tree — blocking work that must stay off the loop.
|
||||||
if normalized == "secrets.yaml":
|
if normalized == "secrets.yaml":
|
||||||
content = _mask_secrets_content(content)
|
content = await hass.async_add_executor_job(_mask_secrets_content, content)
|
||||||
|
|
||||||
# Apply tail for log files
|
# Apply tail for log files
|
||||||
if normalized == "home-assistant.log":
|
if normalized == "home-assistant.log":
|
||||||
@@ -2845,8 +3093,12 @@ def _build_get_caller_token_handler(
|
|||||||
# uses everywhere else.
|
# uses everywhere else.
|
||||||
try:
|
try:
|
||||||
integration = await async_get_integration(hass, DOMAIN)
|
integration = await async_get_integration(hass, DOMAIN)
|
||||||
|
if integration.version is None:
|
||||||
|
# Reads as None rather than raising; an unreadable version and
|
||||||
|
# an absent one deserve the same answer.
|
||||||
|
raise ValueError("the manifest carries no version")
|
||||||
version = str(integration.version)
|
version = str(integration.version)
|
||||||
except Exception as exc: # pragma: no cover — manifest sanity
|
except Exception as exc:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Could not read ha_mcp_tools manifest version for "
|
"Could not read ha_mcp_tools manifest version for "
|
||||||
"get_caller_token response: %s",
|
"get_caller_token response: %s",
|
||||||
@@ -2906,7 +3158,6 @@ def _build_set_allowed_paths_handler(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
"""Build the handle_set_allowed_paths service handler."""
|
"""Build the handle_set_allowed_paths service handler."""
|
||||||
config_dir = Path(hass.config.config_dir)
|
|
||||||
|
|
||||||
async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse:
|
async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse:
|
||||||
"""Replace the user-configurable extra directories (issues #1567, #1586).
|
"""Replace the user-configurable extra directories (issues #1567, #1586).
|
||||||
@@ -2929,18 +3180,9 @@ def _build_set_allowed_paths_handler(
|
|||||||
"error": "ha_mcp_tools.set_allowed_paths requires admin auth.",
|
"error": "ha_mcp_tools.set_allowed_paths requires admin auth.",
|
||||||
"paths": [],
|
"paths": [],
|
||||||
}
|
}
|
||||||
raw_paths = call.data.get("paths", [])
|
normalized, rejected = await _apply_allowed_paths(
|
||||||
normalized: list[str] = []
|
hass, call.data.get("paths", [])
|
||||||
rejected: list[str] = []
|
)
|
||||||
for entry in raw_paths:
|
|
||||||
norm = _normalize_extra_dir(entry, config_dir)
|
|
||||||
if norm is None:
|
|
||||||
rejected.append(entry)
|
|
||||||
elif norm not in normalized:
|
|
||||||
normalized.append(norm)
|
|
||||||
|
|
||||||
await _save_allowed_paths(hass, normalized)
|
|
||||||
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Updated ha_mcp_tools custom filesystem directories: %s (%d rejected)",
|
"Updated ha_mcp_tools custom filesystem directories: %s (%d rejected)",
|
||||||
normalized,
|
normalized,
|
||||||
@@ -2955,6 +3197,77 @@ def _build_set_allowed_paths_handler(
|
|||||||
return handle_set_allowed_paths
|
return handle_set_allowed_paths
|
||||||
|
|
||||||
|
|
||||||
|
def _build_get_extra_yaml_keys_handler(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
|
"""Build the handle_get_extra_yaml_keys service handler (#1887)."""
|
||||||
|
|
||||||
|
async def handle_get_extra_yaml_keys(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Return the component-configured extra YAML write keys plus the
|
||||||
|
non-overridable deny floor.
|
||||||
|
|
||||||
|
Backs the component's own options flow and the ha-mcp settings UI, and
|
||||||
|
is how the server reads this store to union it with its own
|
||||||
|
``HA_MCP_EXTRA_YAML_KEYS``. Caller-token + admin gated, matching
|
||||||
|
get_allowed_paths.
|
||||||
|
"""
|
||||||
|
if not _caller_token_ok(hass, call):
|
||||||
|
return _unauthorized_response(SERVICE_GET_EXTRA_YAML_KEYS, keys=[])
|
||||||
|
if not await _caller_is_admin(hass, call):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error_code": "unauthorized",
|
||||||
|
"error": "ha_mcp_tools.get_extra_yaml_keys requires admin auth.",
|
||||||
|
"keys": [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"keys": _current_extra_yaml_keys(hass),
|
||||||
|
"deny_floor": sorted(YAML_KEY_DENYLIST),
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle_get_extra_yaml_keys
|
||||||
|
|
||||||
|
|
||||||
|
def _build_set_extra_yaml_keys_handler(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
|
"""Build the handle_set_extra_yaml_keys service handler (#1887)."""
|
||||||
|
|
||||||
|
async def handle_set_extra_yaml_keys(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Replace the component-configured extra YAML write keys.
|
||||||
|
|
||||||
|
Receives the FULL replacement list. Each entry is stripped and
|
||||||
|
validated; blanks and ``YAML_KEY_DENYLIST`` members are dropped and
|
||||||
|
reported in ``rejected``. Persists to .storage AND updates hass.data so
|
||||||
|
enforcement applies live with no HA restart. Caller-token + admin gated.
|
||||||
|
"""
|
||||||
|
if not _caller_token_ok(hass, call):
|
||||||
|
return _unauthorized_response(SERVICE_SET_EXTRA_YAML_KEYS, keys=[])
|
||||||
|
if not await _caller_is_admin(hass, call):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error_code": "unauthorized",
|
||||||
|
"error": "ha_mcp_tools.set_extra_yaml_keys requires admin auth.",
|
||||||
|
"keys": [],
|
||||||
|
}
|
||||||
|
normalized, rejected = await _apply_extra_yaml_keys(
|
||||||
|
hass, call.data.get("keys", [])
|
||||||
|
)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Updated ha_mcp_tools extra YAML write keys: %s (%d rejected)",
|
||||||
|
normalized,
|
||||||
|
len(rejected),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"keys": normalized,
|
||||||
|
"rejected": rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle_set_extra_yaml_keys
|
||||||
|
|
||||||
|
|
||||||
def _build_list_legacy_backups_handler(
|
def _build_list_legacy_backups_handler(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
@@ -3063,6 +3376,13 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
|||||||
_HASS_DATA_ALLOWED_PATHS_KEY
|
_HASS_DATA_ALLOWED_PATHS_KEY
|
||||||
] = await _load_allowed_paths(hass)
|
] = await _load_allowed_paths(hass)
|
||||||
|
|
||||||
|
# Load the component-configured extra YAML write keys (#1887) into hass.data
|
||||||
|
# so enforcement reads them with no I/O. set_extra_yaml_keys updates this in
|
||||||
|
# place, so changes apply live (no HA restart).
|
||||||
|
hass.data.setdefault(DOMAIN, {})[
|
||||||
|
_HASS_DATA_EXTRA_YAML_KEYS_KEY
|
||||||
|
] = await _load_extra_yaml_keys(hass)
|
||||||
|
|
||||||
# One-time migration of pre-fix YAML backups out of the publicly-served
|
# One-time migration of pre-fix YAML backups out of the publicly-served
|
||||||
# www/ directory (GHSA-g39v-cvjh-8fpf). Wrapped so a migration failure
|
# www/ directory (GHSA-g39v-cvjh-8fpf). Wrapped so a migration failure
|
||||||
# cannot prevent the integration from loading — the integration's
|
# cannot prevent the integration from loading — the integration's
|
||||||
@@ -3132,6 +3452,8 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
|||||||
handle_get_caller_token = _build_get_caller_token_handler(hass)
|
handle_get_caller_token = _build_get_caller_token_handler(hass)
|
||||||
handle_get_allowed_paths = _build_get_allowed_paths_handler(hass)
|
handle_get_allowed_paths = _build_get_allowed_paths_handler(hass)
|
||||||
handle_set_allowed_paths = _build_set_allowed_paths_handler(hass)
|
handle_set_allowed_paths = _build_set_allowed_paths_handler(hass)
|
||||||
|
handle_get_extra_yaml_keys = _build_get_extra_yaml_keys_handler(hass)
|
||||||
|
handle_set_extra_yaml_keys = _build_set_extra_yaml_keys_handler(hass)
|
||||||
handle_list_legacy_backups = _build_list_legacy_backups_handler(hass)
|
handle_list_legacy_backups = _build_list_legacy_backups_handler(hass)
|
||||||
handle_read_legacy_backup = _build_read_legacy_backup_handler(hass)
|
handle_read_legacy_backup = _build_read_legacy_backup_handler(hass)
|
||||||
|
|
||||||
@@ -3200,6 +3522,22 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
|||||||
supports_response=SupportsResponse.ONLY,
|
supports_response=SupportsResponse.ONLY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_GET_EXTRA_YAML_KEYS,
|
||||||
|
handle_get_extra_yaml_keys,
|
||||||
|
schema=SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_EXTRA_YAML_KEYS,
|
||||||
|
handle_set_extra_yaml_keys,
|
||||||
|
schema=SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_LIST_LEGACY_BACKUPS,
|
SERVICE_LIST_LEGACY_BACKUPS,
|
||||||
@@ -3242,10 +3580,17 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
|||||||
component_version = COMPONENT_VERSION
|
component_version = COMPONENT_VERSION
|
||||||
try:
|
try:
|
||||||
integration = await async_get_integration(hass, DOMAIN)
|
integration = await async_get_integration(hass, DOMAIN)
|
||||||
|
if integration.version is None:
|
||||||
|
# A manifest without a version reads as None rather than raising,
|
||||||
|
# and ``str()`` would put the literal "None" on the device.
|
||||||
|
raise ValueError("the manifest carries no version")
|
||||||
component_version = str(integration.version)
|
component_version = str(integration.version)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Could not read the component version for the tools device: %s", err
|
"Could not read the component version for the tools device, using "
|
||||||
|
"the compiled-in %s: %s",
|
||||||
|
COMPONENT_VERSION,
|
||||||
|
err,
|
||||||
)
|
)
|
||||||
dr.async_get(hass).async_get_or_create(
|
dr.async_get(hass).async_get_or_create(
|
||||||
config_entry_id=entry.entry_id,
|
config_entry_id=entry.entry_id,
|
||||||
|
|||||||
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user