Compare commits
1640
Commits
c7a11845cd
...
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": 1784857755.5994709}
|
{"pid": 71, "version": 1, "ha_version": "2026.8.1", "start_ts": 1786460801.8751855}
|
||||||
@@ -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",
|
||||||
@@ -415,31 +565,6 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"*meter00*"
|
"*meter00*"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "custom:auto-entities",
|
|
||||||
"card": {
|
|
||||||
"type": "grid",
|
|
||||||
"columns": 2,
|
|
||||||
"square": false
|
|
||||||
},
|
|
||||||
"filter": {
|
|
||||||
"include": [
|
|
||||||
{
|
|
||||||
"domain": "light",
|
|
||||||
"options": {
|
|
||||||
"type": "custom:mushroom-light-card",
|
|
||||||
"use_light_color": true,
|
|
||||||
"show_brightness_control": true,
|
|
||||||
"show_color_temp_control": true,
|
|
||||||
"collapsible_controls": true,
|
|
||||||
"tap_action": {
|
|
||||||
"action": "toggle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,6 +130,20 @@
|
|||||||
"double_tap_action": {
|
"double_tap_action": {
|
||||||
"action": "none"
|
"action": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:mushroom-light-card",
|
||||||
|
"entity": "light.office_lamp",
|
||||||
|
"use_light_color": true,
|
||||||
|
"tap_action": {
|
||||||
|
"action": "none"
|
||||||
|
},
|
||||||
|
"hold_action": {
|
||||||
|
"action": "none"
|
||||||
|
},
|
||||||
|
"double_tap_action": {
|
||||||
|
"action": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-42
@@ -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=939311749350",
|
||||||
"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.1.0",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "0796c92931dd458e81dc9c2677248274",
|
"id": "0796c92931dd458e81dc9c2677248274",
|
||||||
"url": "/taskmate/taskmate-localize.js?v=5.0.1",
|
"url": "/taskmate/taskmate-localize.js?v=5.1.0",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d2e281920ac7409583eafff1f1db69bd",
|
"id": "d2e281920ac7409583eafff1f1db69bd",
|
||||||
"url": "/taskmate/taskmate-design.js?v=5.0.1",
|
"url": "/taskmate/taskmate-design.js?v=5.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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.1.0",
|
||||||
"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=1789210374101",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -284,18 +279,38 @@
|
|||||||
"url": "/hacsfiles/uptime-card/uptime-card.js?hacstag=3505098670160",
|
"url": "/hacsfiles/uptime-card/uptime-card.js?hacstag=3505098670160",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "a3fc4486bc2c4edab1f38af295b4ef3f",
|
|
||||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.5.0",
|
|
||||||
"type": "module"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
||||||
"url": "/taskmate/taskmate-routine-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-routine-card.js?v=5.1.0",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1804d28af99b483bb07918647b8a3dda",
|
"id": "24acdbe1f5fb4bbdb2f16b69d515d549",
|
||||||
|
"url": "/hacsfiles/ha-power-gauge/ha-power-gauge.js?hacstag=1233333695024",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9d164a6504b2427ba34497d5bf035fb4",
|
||||||
|
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "630cb889ebde4f41a2caa24597e156a7",
|
||||||
|
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "488d448822ab42869df9a07fbb87d1ed",
|
||||||
|
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.7.1",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8202c7b2a7bd4c3e87e57bcd3c97e818",
|
||||||
|
"url": "/local/gcode_viewer_card.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "08693d7653c449df9be662ba8bdac1a7",
|
||||||
"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
-2435
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()
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -52,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,
|
||||||
@@ -61,12 +62,24 @@ from .const import (
|
|||||||
CONF_SYNOLOGY_SPACE,
|
CONF_SYNOLOGY_SPACE,
|
||||||
CONF_SYNOLOGY_ALBUM_ID,
|
CONF_SYNOLOGY_ALBUM_ID,
|
||||||
CONF_SYNOLOGY_IMAGE_SIZE,
|
CONF_SYNOLOGY_IMAGE_SIZE,
|
||||||
|
CONF_SYNOLOGY_PASSPHRASE,
|
||||||
|
CONF_SYNOLOGY_FAVORITE,
|
||||||
|
CONF_SYNOLOGY_SELECTION,
|
||||||
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
||||||
SYNOLOGY_SPACE_PERSONAL,
|
SYNOLOGY_SPACE_PERSONAL,
|
||||||
SYNOLOGY_SPACE_SHARED,
|
SYNOLOGY_SPACE_SHARED,
|
||||||
SYNOLOGY_IMAGE_SMALL,
|
SYNOLOGY_IMAGE_SMALL,
|
||||||
SYNOLOGY_IMAGE_MEDIUM,
|
SYNOLOGY_IMAGE_MEDIUM,
|
||||||
SYNOLOGY_IMAGE_LARGE,
|
SYNOLOGY_IMAGE_LARGE,
|
||||||
|
CONF_NEXTCLOUD_URL,
|
||||||
|
CONF_NEXTCLOUD_USERNAME,
|
||||||
|
CONF_NEXTCLOUD_PASSWORD,
|
||||||
|
CONF_NEXTCLOUD_FOLDER,
|
||||||
|
CONF_NEXTCLOUD_RECURSIVE,
|
||||||
|
CONF_NEXTCLOUD_IMAGE_SIZE,
|
||||||
|
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
|
||||||
|
NEXTCLOUD_IMAGE_PREVIEW,
|
||||||
|
NEXTCLOUD_IMAGE_ORIGINAL,
|
||||||
DEFAULT_REVERSE_GEOCODE,
|
DEFAULT_REVERSE_GEOCODE,
|
||||||
PROVIDER_GOOGLE_SHARED,
|
PROVIDER_GOOGLE_SHARED,
|
||||||
PROVIDER_LOCAL_FOLDER,
|
PROVIDER_LOCAL_FOLDER,
|
||||||
@@ -75,6 +88,7 @@ from .const import (
|
|||||||
PROVIDER_PHOTOPRISM,
|
PROVIDER_PHOTOPRISM,
|
||||||
PROVIDER_ICLOUD,
|
PROVIDER_ICLOUD,
|
||||||
PROVIDER_SYNOLOGY,
|
PROVIDER_SYNOLOGY,
|
||||||
|
PROVIDER_NEXTCLOUD,
|
||||||
DEFAULT_RECURSIVE,
|
DEFAULT_RECURSIVE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,6 +143,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self._syn_device_id: str | None = None
|
self._syn_device_id: str | None = None
|
||||||
self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
|
self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
|
||||||
self._syn_albums: dict[str, str] = {}
|
self._syn_albums: dict[str, str] = {}
|
||||||
|
# option key -> {"album_id": id|None, "passphrase": str|None}
|
||||||
|
self._syn_album_meta: dict[str, dict[str, Any]] = {}
|
||||||
|
# id(str) -> name maps for the composite category multi-selects.
|
||||||
|
self._syn_people: dict[str, str] = {}
|
||||||
|
self._syn_places: dict[str, str] = {}
|
||||||
|
self._syn_tags: dict[str, str] = {}
|
||||||
|
self._syn_subjects: dict[str, str] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
@@ -147,7 +168,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
``__init__`` raises (the symptom is a 500 when the user clicks
|
``__init__`` raises (the symptom is a 500 when the user clicks
|
||||||
Configure).
|
Configure).
|
||||||
"""
|
"""
|
||||||
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER:
|
if config_entry.data.get(CONF_PROVIDER) in (
|
||||||
|
PROVIDER_LOCAL_FOLDER,
|
||||||
|
PROVIDER_NEXTCLOUD,
|
||||||
|
):
|
||||||
return LocalFolderOptionsFlow()
|
return LocalFolderOptionsFlow()
|
||||||
return _NoOptionsFlow()
|
return _NoOptionsFlow()
|
||||||
|
|
||||||
@@ -166,6 +190,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return await self.async_step_icloud()
|
return await self.async_step_icloud()
|
||||||
if self._provider == PROVIDER_SYNOLOGY:
|
if self._provider == PROVIDER_SYNOLOGY:
|
||||||
return await self.async_step_synology()
|
return await self.async_step_synology()
|
||||||
|
if self._provider == PROVIDER_NEXTCLOUD:
|
||||||
|
return await self.async_step_nextcloud()
|
||||||
return await self.async_step_google_shared()
|
return await self.async_step_google_shared()
|
||||||
|
|
||||||
schema = vol.Schema(
|
schema = vol.Schema(
|
||||||
@@ -177,6 +203,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
|
||||||
PROVIDER_ICLOUD: "iCloud Shared Album",
|
PROVIDER_ICLOUD: "iCloud Shared Album",
|
||||||
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
|
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
|
||||||
|
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder, full metadata)",
|
||||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -193,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,
|
||||||
@@ -223,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,
|
||||||
@@ -257,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(
|
||||||
@@ -369,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()
|
||||||
@@ -548,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()
|
||||||
@@ -633,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(
|
||||||
@@ -652,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,
|
||||||
},
|
},
|
||||||
@@ -699,12 +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 = await client.async_list_albums()
|
|
||||||
except syn_api.SynologyOtpRequired:
|
except syn_api.SynologyOtpRequired:
|
||||||
errors["otp_code"] = "synology_otp_required"
|
errors["otp_code"] = "synology_otp_required"
|
||||||
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
|
||||||
@@ -712,10 +767,33 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
# A trusted-device token is captured only on the OTP login;
|
# A trusted-device token is captured only on the OTP login;
|
||||||
# store it so future logins skip the 2FA prompt.
|
# store it so future logins skip the 2FA prompt.
|
||||||
self._syn_device_id = client.captured_device_id
|
self._syn_device_id = client.captured_device_id
|
||||||
self._syn_albums = {
|
# Key each album by a synthetic value so an own album and a
|
||||||
str(a["id"]): (a.get("name") or str(a["id"]))
|
# shared-with-me album that happen to share a numeric id don't
|
||||||
for a in albums
|
# collide. Track album_id + passphrase per option.
|
||||||
if a.get("id") is not None
|
self._syn_albums = {}
|
||||||
|
self._syn_album_meta = {}
|
||||||
|
for a in albums:
|
||||||
|
if a.get("id") is None:
|
||||||
|
continue
|
||||||
|
shared = bool(a.get("shared"))
|
||||||
|
key = f"{'shared' if shared else 'own'}:{a['id']}"
|
||||||
|
label = a.get("name") or str(a["id"])
|
||||||
|
self._syn_albums[key] = f"{label} (shared)" if shared else label
|
||||||
|
self._syn_album_meta[key] = {
|
||||||
|
"album_id": None if shared else a["id"],
|
||||||
|
"passphrase": a.get("passphrase") if shared else None,
|
||||||
|
}
|
||||||
|
self._syn_people = {
|
||||||
|
str(p["id"]): p["name"] for p in people if p.get("id") is not None
|
||||||
|
}
|
||||||
|
self._syn_places = {
|
||||||
|
str(p["id"]): p["name"] for p in places if p.get("id") is not None
|
||||||
|
}
|
||||||
|
self._syn_tags = {
|
||||||
|
str(t["id"]): t["name"] for t in tags if t.get("id") is not None
|
||||||
|
}
|
||||||
|
self._syn_subjects = {
|
||||||
|
str(s["id"]): s["name"] for s in subjects if s.get("id") is not None
|
||||||
}
|
}
|
||||||
await client.async_logout()
|
await client.async_logout()
|
||||||
return await self.async_step_synology_select()
|
return await self.async_step_synology_select()
|
||||||
@@ -745,7 +823,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
async def async_step_synology_select(
|
async def async_step_synology_select(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Pick a Synology album (or the whole space) and image size."""
|
"""Build a composite Synology selection and finish the entry.
|
||||||
|
|
||||||
|
Like the Immich/PhotoPrism providers: tick any mix of favorites,
|
||||||
|
albums, people, places, tags and subjects. Synology has no OR across
|
||||||
|
categories, so each member is queried on its own and merged. An empty
|
||||||
|
selection means the whole space.
|
||||||
|
"""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
@@ -753,13 +837,42 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
size = user_input.get(
|
size = user_input.get(
|
||||||
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||||
)
|
)
|
||||||
album_id = user_input.get("album")
|
favorites = bool(user_input.get("favorites"))
|
||||||
if album_id in (None, "", "__all__") or album_id not in self._syn_albums:
|
|
||||||
album_id = ""
|
|
||||||
|
|
||||||
|
album_ids: list[Any] = []
|
||||||
|
passphrases: list[str] = []
|
||||||
|
for key in user_input.get("albums", []) or []:
|
||||||
|
meta = self._syn_album_meta.get(key)
|
||||||
|
if not meta:
|
||||||
|
continue
|
||||||
|
if meta.get("passphrase"):
|
||||||
|
passphrases.append(meta["passphrase"])
|
||||||
|
elif meta.get("album_id") is not None:
|
||||||
|
album_ids.append(meta["album_id"])
|
||||||
|
|
||||||
|
def _ids(field: str, valid: dict[str, str]) -> list[int]:
|
||||||
|
out: list[int] = []
|
||||||
|
for v in user_input.get(field, []) or []:
|
||||||
|
if v in valid:
|
||||||
|
try:
|
||||||
|
out.append(int(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
selection = {
|
||||||
|
"favorites": favorites,
|
||||||
|
"album_ids": album_ids,
|
||||||
|
"passphrases": passphrases,
|
||||||
|
"person_ids": _ids("people", self._syn_people),
|
||||||
|
"geocoding_ids": _ids("places", self._syn_places),
|
||||||
|
"tag_ids": _ids("tags", self._syn_tags),
|
||||||
|
"concept_ids": _ids("subjects", self._syn_subjects),
|
||||||
|
}
|
||||||
|
sel_id = json.dumps(selection, sort_keys=True)
|
||||||
unique = (
|
unique = (
|
||||||
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
||||||
f"{self._syn_space}:{album_id or 'all'}"
|
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()
|
||||||
@@ -769,44 +882,128 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_SYNOLOGY_USERNAME: self._syn_username,
|
CONF_SYNOLOGY_USERNAME: self._syn_username,
|
||||||
CONF_SYNOLOGY_PASSWORD: self._syn_password,
|
CONF_SYNOLOGY_PASSWORD: self._syn_password,
|
||||||
CONF_SYNOLOGY_SPACE: self._syn_space,
|
CONF_SYNOLOGY_SPACE: self._syn_space,
|
||||||
|
CONF_SYNOLOGY_SELECTION: sel_id,
|
||||||
CONF_SYNOLOGY_IMAGE_SIZE: size,
|
CONF_SYNOLOGY_IMAGE_SIZE: size,
|
||||||
CONF_ALBUM_NAME: name,
|
CONF_ALBUM_NAME: name,
|
||||||
}
|
}
|
||||||
if album_id:
|
|
||||||
data[CONF_SYNOLOGY_ALBUM_ID] = album_id
|
|
||||||
if self._syn_device_id:
|
if self._syn_device_id:
|
||||||
data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id
|
data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id
|
||||||
return self.async_create_entry(title=name, data=data)
|
return self.async_create_entry(title=name, data=data)
|
||||||
|
|
||||||
album_options = [
|
def _multi(options: dict[str, str]):
|
||||||
selector.SelectOptionDict(value="__all__", label="All photos in this space")
|
return selector.SelectSelector(
|
||||||
] + [
|
selector.SelectSelectorConfig(
|
||||||
selector.SelectOptionDict(value=aid, label=aname)
|
options=[
|
||||||
for aid, aname in self._syn_albums.items()
|
selector.SelectOptionDict(value=v, label=l)
|
||||||
]
|
for v, l in options.items()
|
||||||
|
],
|
||||||
|
multiple=True,
|
||||||
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||||
|
custom_value=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
|
||||||
|
# Favorites, albums and subjects are Personal-space concepts.
|
||||||
|
if self._syn_space == SYNOLOGY_SPACE_PERSONAL:
|
||||||
|
fields[vol.Optional("favorites", default=False)] = (
|
||||||
|
selector.BooleanSelector()
|
||||||
|
)
|
||||||
|
if self._syn_albums:
|
||||||
|
fields[vol.Optional("albums")] = _multi(self._syn_albums)
|
||||||
|
if self._syn_people:
|
||||||
|
fields[vol.Optional("people")] = _multi(self._syn_people)
|
||||||
|
if self._syn_places:
|
||||||
|
fields[vol.Optional("places")] = _multi(self._syn_places)
|
||||||
|
if self._syn_tags:
|
||||||
|
fields[vol.Optional("tags")] = _multi(self._syn_tags)
|
||||||
|
if self._syn_subjects:
|
||||||
|
fields[vol.Optional("subjects")] = _multi(self._syn_subjects)
|
||||||
|
fields[
|
||||||
|
vol.Optional(
|
||||||
|
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||||
|
)
|
||||||
|
] = vol.In(
|
||||||
|
{
|
||||||
|
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
|
||||||
|
SYNOLOGY_IMAGE_MEDIUM: "Medium",
|
||||||
|
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="synology_select", data_schema=vol.Schema(fields), errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_nextcloud(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Collect and validate a Nextcloud WebDAV folder + app password."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
name = user_input[CONF_ALBUM_NAME].strip()
|
||||||
|
url = user_input[CONF_NEXTCLOUD_URL].strip()
|
||||||
|
username = user_input[CONF_NEXTCLOUD_USERNAME].strip()
|
||||||
|
password = user_input.get(CONF_NEXTCLOUD_PASSWORD) or ""
|
||||||
|
folder = (user_input.get(CONF_NEXTCLOUD_FOLDER) or "").strip()
|
||||||
|
recursive = bool(user_input.get(CONF_NEXTCLOUD_RECURSIVE, False))
|
||||||
|
size = user_input.get(
|
||||||
|
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import nextcloud as nc_api
|
||||||
|
|
||||||
|
client = nc_api.NextcloudClient(
|
||||||
|
self.hass, url, username, password, folder
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await client.async_validate()
|
||||||
|
except Exception: # noqa: BLE001 - any failure means bad URL/creds/folder
|
||||||
|
errors["base"] = "nextcloud_cannot_connect"
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(
|
||||||
|
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
|
||||||
|
f"{username}:{client.folder}:{name}"
|
||||||
|
)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=name,
|
||||||
|
data={
|
||||||
|
CONF_PROVIDER: PROVIDER_NEXTCLOUD,
|
||||||
|
CONF_NEXTCLOUD_URL: client.base_url,
|
||||||
|
CONF_NEXTCLOUD_USERNAME: username,
|
||||||
|
CONF_NEXTCLOUD_PASSWORD: password,
|
||||||
|
CONF_NEXTCLOUD_FOLDER: client.folder,
|
||||||
|
CONF_NEXTCLOUD_RECURSIVE: recursive,
|
||||||
|
CONF_NEXTCLOUD_IMAGE_SIZE: size,
|
||||||
|
CONF_ALBUM_NAME: name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
schema = vol.Schema(
|
schema = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_ALBUM_NAME): str,
|
vol.Required(CONF_ALBUM_NAME): str,
|
||||||
vol.Optional("album", default="__all__"): selector.SelectSelector(
|
vol.Required(CONF_NEXTCLOUD_URL): str,
|
||||||
selector.SelectSelectorConfig(
|
vol.Required(CONF_NEXTCLOUD_USERNAME): str,
|
||||||
options=album_options,
|
vol.Required(CONF_NEXTCLOUD_PASSWORD): selector.TextSelector(
|
||||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||||
custom_value=False,
|
),
|
||||||
)
|
vol.Optional(CONF_NEXTCLOUD_FOLDER, default=""): str,
|
||||||
|
vol.Optional(CONF_NEXTCLOUD_RECURSIVE, default=False): (
|
||||||
|
selector.BooleanSelector()
|
||||||
),
|
),
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
|
CONF_NEXTCLOUD_IMAGE_SIZE, default=DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||||
): vol.In(
|
): vol.In(
|
||||||
{
|
{
|
||||||
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
|
NEXTCLOUD_IMAGE_PREVIEW: "Preview (smoothest slideshow)",
|
||||||
SYNOLOGY_IMAGE_MEDIUM: "Medium",
|
NEXTCLOUD_IMAGE_ORIGINAL: "Original (full quality, slower)",
|
||||||
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="synology_select", data_schema=schema, errors=errors
|
step_id="nextcloud", data_schema=schema, errors=errors
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,28 @@ PROVIDER_IMMICH = "immich"
|
|||||||
PROVIDER_PHOTOPRISM = "photoprism"
|
PROVIDER_PHOTOPRISM = "photoprism"
|
||||||
PROVIDER_ICLOUD = "icloud"
|
PROVIDER_ICLOUD = "icloud"
|
||||||
PROVIDER_SYNOLOGY = "synology"
|
PROVIDER_SYNOLOGY = "synology"
|
||||||
|
PROVIDER_NEXTCLOUD = "nextcloud"
|
||||||
|
|
||||||
|
# Nextcloud (authenticated WebDAV folder) provider. Points at any folder in a
|
||||||
|
# user's files and lists it over WebDAV. Auth is HTTP Basic with a username +
|
||||||
|
# app password (Settings > Security > Devices & sessions); the app password is
|
||||||
|
# stored so the coordinator can re-list on each refresh and is sent server-side
|
||||||
|
# only, never reaching the browser.
|
||||||
|
CONF_NEXTCLOUD_URL = "nextcloud_url"
|
||||||
|
CONF_NEXTCLOUD_USERNAME = "nextcloud_username"
|
||||||
|
CONF_NEXTCLOUD_PASSWORD = "nextcloud_password"
|
||||||
|
CONF_NEXTCLOUD_FOLDER = "nextcloud_folder"
|
||||||
|
CONF_NEXTCLOUD_RECURSIVE = "nextcloud_recursive"
|
||||||
|
CONF_NEXTCLOUD_IMAGE_SIZE = "nextcloud_image_size"
|
||||||
|
|
||||||
|
# ``preview`` uses the core/preview thumbnail endpoint (smoother, smaller);
|
||||||
|
# ``original`` fetches the real file straight off the WebDAV collection.
|
||||||
|
NEXTCLOUD_IMAGE_PREVIEW = "preview"
|
||||||
|
NEXTCLOUD_IMAGE_ORIGINAL = "original"
|
||||||
|
NEXTCLOUD_IMAGE_SIZE_OPTIONS = [NEXTCLOUD_IMAGE_PREVIEW, NEXTCLOUD_IMAGE_ORIGINAL]
|
||||||
|
DEFAULT_NEXTCLOUD_IMAGE_SIZE = NEXTCLOUD_IMAGE_PREVIEW
|
||||||
|
# Long edge (px) requested from the core/preview endpoint for preview quality.
|
||||||
|
NEXTCLOUD_PREVIEW_PX = 1920
|
||||||
|
|
||||||
# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
|
# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
|
||||||
# entry.cgi web API. The account password is stored so the coordinator can
|
# entry.cgi web API. The account password is stored so the coordinator can
|
||||||
@@ -70,6 +92,21 @@ CONF_SYNOLOGY_DEVICE_ID = "synology_device_id"
|
|||||||
CONF_SYNOLOGY_SPACE = "synology_space"
|
CONF_SYNOLOGY_SPACE = "synology_space"
|
||||||
CONF_SYNOLOGY_ALBUM_ID = "synology_album_id"
|
CONF_SYNOLOGY_ALBUM_ID = "synology_album_id"
|
||||||
CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size"
|
CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size"
|
||||||
|
# Passphrase for an album that was shared with the configured account. Present
|
||||||
|
# only when the chosen source is a shared-with-me album; such albums are
|
||||||
|
# reachable by passphrase rather than by album id.
|
||||||
|
CONF_SYNOLOGY_PASSPHRASE = "synology_passphrase"
|
||||||
|
# When True, the source is the account's Favorites (favorited photos) rather
|
||||||
|
# than the whole space or a specific album.
|
||||||
|
CONF_SYNOLOGY_FAVORITE = "synology_favorite"
|
||||||
|
# Composite selection: a client-side union of any mix of albums, people,
|
||||||
|
# places, tags, subjects and favorites. Synology has no OR across categories,
|
||||||
|
# so each selected member is queried on its own and the results are merged
|
||||||
|
# (see the Immich/PhotoPrism composite). Stored as a JSON object:
|
||||||
|
# ``{"favorites": bool, "album_ids": [...], "passphrases": [...],
|
||||||
|
# "person_ids": [...], "geocoding_ids": [...], "tag_ids": [...],
|
||||||
|
# "concept_ids": [...]}``; an empty composite means the whole space.
|
||||||
|
CONF_SYNOLOGY_SELECTION = "synology_selection"
|
||||||
|
|
||||||
# Personal ("My Photos") vs shared ("Shared Space") library.
|
# Personal ("My Photos") vs shared ("Shared Space") library.
|
||||||
SYNOLOGY_SPACE_PERSONAL = "personal"
|
SYNOLOGY_SPACE_PERSONAL = "personal"
|
||||||
@@ -92,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,
|
||||||
@@ -51,8 +54,20 @@ from .const import (
|
|||||||
CONF_SYNOLOGY_SPACE,
|
CONF_SYNOLOGY_SPACE,
|
||||||
CONF_SYNOLOGY_ALBUM_ID,
|
CONF_SYNOLOGY_ALBUM_ID,
|
||||||
CONF_SYNOLOGY_IMAGE_SIZE,
|
CONF_SYNOLOGY_IMAGE_SIZE,
|
||||||
|
CONF_SYNOLOGY_PASSPHRASE,
|
||||||
|
CONF_SYNOLOGY_FAVORITE,
|
||||||
|
CONF_SYNOLOGY_SELECTION,
|
||||||
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
DEFAULT_SYNOLOGY_IMAGE_SIZE,
|
||||||
SYNOLOGY_SPACE_PERSONAL,
|
SYNOLOGY_SPACE_PERSONAL,
|
||||||
|
CONF_NEXTCLOUD_URL,
|
||||||
|
CONF_NEXTCLOUD_USERNAME,
|
||||||
|
CONF_NEXTCLOUD_PASSWORD,
|
||||||
|
CONF_NEXTCLOUD_FOLDER,
|
||||||
|
CONF_NEXTCLOUD_RECURSIVE,
|
||||||
|
CONF_NEXTCLOUD_IMAGE_SIZE,
|
||||||
|
DEFAULT_NEXTCLOUD_IMAGE_SIZE,
|
||||||
|
NEXTCLOUD_IMAGE_ORIGINAL,
|
||||||
|
NEXTCLOUD_PREVIEW_PX,
|
||||||
DEFAULT_REVERSE_GEOCODE,
|
DEFAULT_REVERSE_GEOCODE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PROVIDER_GOOGLE_SHARED,
|
PROVIDER_GOOGLE_SHARED,
|
||||||
@@ -62,6 +77,7 @@ from .const import (
|
|||||||
PROVIDER_PHOTOPRISM,
|
PROVIDER_PHOTOPRISM,
|
||||||
PROVIDER_ICLOUD,
|
PROVIDER_ICLOUD,
|
||||||
PROVIDER_SYNOLOGY,
|
PROVIDER_SYNOLOGY,
|
||||||
|
PROVIDER_NEXTCLOUD,
|
||||||
)
|
)
|
||||||
from .store import SlideshowStore
|
from .store import SlideshowStore
|
||||||
|
|
||||||
@@ -420,6 +436,11 @@ _NOMINATIM_TIMEOUT_S = 20
|
|||||||
_EXIF_BATCH_SAVE = 25
|
_EXIF_BATCH_SAVE = 25
|
||||||
_GEOCODE_BATCH_SAVE = 10
|
_GEOCODE_BATCH_SAVE = 10
|
||||||
|
|
||||||
|
# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first
|
||||||
|
# blocks of the file, but we read the whole thing since Pillow needs a complete
|
||||||
|
# image; this bounds memory for pathological files. 64 MB matches the camera.
|
||||||
|
_NEXTCLOUD_ENRICH_MAX_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
# Inserted between background-enrichment iterations so the event loop
|
# Inserted between background-enrichment iterations so the event loop
|
||||||
# stays responsive on the fast path (items that are already scanned and
|
# stays responsive on the fast path (items that are already scanned and
|
||||||
# need zero work).
|
# need zero work).
|
||||||
@@ -674,53 +695,90 @@ def _read_local_exif(path: Path) -> dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with Image.open(path) as img:
|
with Image.open(path) as img:
|
||||||
exif = img.getexif()
|
_read_exif_from_image(img, out)
|
||||||
|
|
||||||
if exif:
|
|
||||||
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
|
|
||||||
_EXIF_TAG_DATETIME
|
|
||||||
)
|
|
||||||
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
|
|
||||||
parsed = _parse_exif_datetime(dt_raw, offset_raw)
|
|
||||||
if parsed is not None:
|
|
||||||
out["captured_at"] = parsed
|
|
||||||
|
|
||||||
# Description can come from IPTC / XMP even when the file has no
|
|
||||||
# EXIF IFD, so this runs regardless of ``exif`` being present.
|
|
||||||
description = _read_photo_description(img, exif)
|
|
||||||
if description:
|
|
||||||
out["description"] = description
|
|
||||||
|
|
||||||
if not exif:
|
|
||||||
return out
|
|
||||||
|
|
||||||
gps = None
|
|
||||||
try:
|
|
||||||
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
|
|
||||||
except Exception:
|
|
||||||
gps = None
|
|
||||||
if gps:
|
|
||||||
lat = _gps_to_decimal(
|
|
||||||
gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF)
|
|
||||||
)
|
|
||||||
lon = _gps_to_decimal(
|
|
||||||
gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF)
|
|
||||||
)
|
|
||||||
if lat is not None and lon is not None:
|
|
||||||
# Null Island guard: GPS chips and some editors stamp
|
|
||||||
# ``(0, 0)`` when the fix is invalid. Treat that as no
|
|
||||||
# location rather than dropping every such photo onto
|
|
||||||
# the equator off the African coast.
|
|
||||||
if abs(lat) < 1e-6 and abs(lon) < 1e-6:
|
|
||||||
return out
|
|
||||||
out["latitude"] = lat
|
|
||||||
out["longitude"] = lon
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.debug("EXIF: failed to read %s: %s", path, err)
|
_LOGGER.debug("EXIF: failed to read %s: %s", path, err)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _read_exif_from_image(img: Any, out: dict[str, Any]) -> None:
|
||||||
|
"""Fill ``out`` with capture date / description / GPS from an open image.
|
||||||
|
|
||||||
|
Shared by ``_read_local_exif`` (opens from a filesystem path) and
|
||||||
|
``_read_exif_from_bytes`` (opens from downloaded bytes, e.g. the
|
||||||
|
Nextcloud provider) - both hand this an already-``Image.open``'d image
|
||||||
|
plus a dict pre-seeded with a fallback ``captured_at``.
|
||||||
|
"""
|
||||||
|
exif = img.getexif()
|
||||||
|
|
||||||
|
if exif:
|
||||||
|
dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get(
|
||||||
|
_EXIF_TAG_DATETIME
|
||||||
|
)
|
||||||
|
offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL)
|
||||||
|
parsed = _parse_exif_datetime(dt_raw, offset_raw)
|
||||||
|
if parsed is not None:
|
||||||
|
out["captured_at"] = parsed
|
||||||
|
|
||||||
|
# Description can come from IPTC / XMP even when the file has no EXIF
|
||||||
|
# IFD, so this runs regardless of ``exif`` being present.
|
||||||
|
description = _read_photo_description(img, exif)
|
||||||
|
if description:
|
||||||
|
out["description"] = description
|
||||||
|
|
||||||
|
if not exif:
|
||||||
|
return
|
||||||
|
|
||||||
|
gps = None
|
||||||
|
try:
|
||||||
|
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
|
||||||
|
except Exception:
|
||||||
|
gps = None
|
||||||
|
if gps:
|
||||||
|
lat = _gps_to_decimal(gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF))
|
||||||
|
lon = _gps_to_decimal(gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF))
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
# Null Island guard: GPS chips and some editors stamp ``(0, 0)``
|
||||||
|
# when the fix is invalid. Treat that as no location rather than
|
||||||
|
# dropping every such photo onto the equator off the African coast.
|
||||||
|
if abs(lat) < 1e-6 and abs(lon) < 1e-6:
|
||||||
|
return
|
||||||
|
out["latitude"] = lat
|
||||||
|
out["longitude"] = lon
|
||||||
|
|
||||||
|
|
||||||
|
def _read_exif_from_bytes(
|
||||||
|
data: bytes, mtime_fallback_ms: int | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read EXIF metadata from already-downloaded image bytes.
|
||||||
|
|
||||||
|
Same return shape as ``_read_local_exif``, for providers (Nextcloud)
|
||||||
|
whose files live on a remote server rather than the local filesystem -
|
||||||
|
the caller downloads the file once for enrichment, regardless of which
|
||||||
|
quality is used for display. ``mtime_fallback_ms`` takes the place of
|
||||||
|
the filesystem mtime fallback (e.g. the WebDAV ``Last-Modified`` date).
|
||||||
|
"""
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
if isinstance(mtime_fallback_ms, int):
|
||||||
|
out["captured_at"] = mtime_fallback_ms
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except Exception: # pragma: no cover - Pillow ships with HA core
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
import io
|
||||||
|
|
||||||
|
with Image.open(io.BytesIO(data)) as img:
|
||||||
|
_read_exif_from_image(img, out)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.debug("EXIF: failed to read image bytes: %s", err)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _format_nominatim_location(payload: dict[str, Any]) -> str | None:
|
def _format_nominatim_location(payload: dict[str, Any]) -> str | None:
|
||||||
"""Turn a Nominatim reverse-geocode response into a short label.
|
"""Turn a Nominatim reverse-geocode response into a short label.
|
||||||
|
|
||||||
@@ -951,6 +1009,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
data = await self._update_icloud()
|
data = await self._update_icloud()
|
||||||
elif self.provider == PROVIDER_SYNOLOGY:
|
elif self.provider == PROVIDER_SYNOLOGY:
|
||||||
data = await self._update_synology()
|
data = await self._update_synology()
|
||||||
|
elif self.provider == PROVIDER_NEXTCLOUD:
|
||||||
|
data = await self._update_nextcloud()
|
||||||
else:
|
else:
|
||||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||||
except UpdateFailed:
|
except UpdateFailed:
|
||||||
@@ -964,7 +1024,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
items = data.get("items") or []
|
items = data.get("items") or []
|
||||||
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) and items:
|
if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH, PROVIDER_NEXTCLOUD) and items:
|
||||||
# Carry forward EXIF/geocode metadata for items we've already
|
# Carry forward EXIF/geocode metadata for items we've already
|
||||||
# scanned this session; new items get filled in by the
|
# scanned this session; new items get filled in by the
|
||||||
# background worker below.
|
# background worker below.
|
||||||
@@ -1466,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()
|
||||||
@@ -1524,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.
|
||||||
|
|
||||||
@@ -1541,6 +1643,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
|
device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
|
||||||
space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
|
space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
|
||||||
album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
|
album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
|
||||||
|
passphrase = self.entry.data.get(CONF_SYNOLOGY_PASSPHRASE)
|
||||||
|
favorite_only = bool(self.entry.data.get(CONF_SYNOLOGY_FAVORITE))
|
||||||
|
selection_raw = self.entry.data.get(CONF_SYNOLOGY_SELECTION)
|
||||||
size = self.entry.data.get(
|
size = self.entry.data.get(
|
||||||
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
|
||||||
)
|
)
|
||||||
@@ -1557,7 +1662,23 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await client.async_login()
|
await client.async_login()
|
||||||
photos = await client.async_collect_assets(album_id or None)
|
if selection_raw:
|
||||||
|
# Composite selection (albums + people + places + tags +
|
||||||
|
# subjects + favorites), merged client-side.
|
||||||
|
try:
|
||||||
|
selection = json.loads(selection_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
selection = {}
|
||||||
|
photos = await client.async_collect_composite(selection)
|
||||||
|
else:
|
||||||
|
# Legacy single-source entries (favorites / one album / all).
|
||||||
|
photos = await client.async_collect_assets(
|
||||||
|
album_id or None,
|
||||||
|
passphrase=passphrase or None,
|
||||||
|
favorite_only=favorite_only,
|
||||||
|
)
|
||||||
|
except syn_api.SynologyPermissionError as err:
|
||||||
|
raise UpdateFailed(str(err)) from err
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
|
raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
|
||||||
|
|
||||||
@@ -1577,10 +1698,19 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
continue
|
continue
|
||||||
unit_id, cache_key = ref
|
unit_id, cache_key = ref
|
||||||
meta = syn_api.parse_photo_meta(p)
|
meta = syn_api.parse_photo_meta(p)
|
||||||
|
# Items pulled from a shared-with-me album carry their own
|
||||||
|
# passphrase (composite path); fall back to the single-album
|
||||||
|
# passphrase for legacy entries.
|
||||||
|
item_pp = p.get("_passphrase") or passphrase or None
|
||||||
items.append(
|
items.append(
|
||||||
MediaItem(
|
MediaItem(
|
||||||
url=syn_api.build_thumbnail_url(
|
url=syn_api.build_thumbnail_url(
|
||||||
client.base_url, unit_id, cache_key, size, space
|
client.base_url,
|
||||||
|
unit_id,
|
||||||
|
cache_key,
|
||||||
|
size,
|
||||||
|
space,
|
||||||
|
passphrase=item_pp,
|
||||||
),
|
),
|
||||||
width=meta.get("width"),
|
width=meta.get("width"),
|
||||||
height=meta.get("height"),
|
height=meta.get("height"),
|
||||||
@@ -1605,6 +1735,143 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _update_nextcloud(self) -> dict[str, Any]:
|
||||||
|
"""List photos from an authenticated Nextcloud WebDAV folder.
|
||||||
|
|
||||||
|
The PROPFIND listing carries filename/size/content-type/mtime but no
|
||||||
|
EXIF, so capture date, GPS and description are filled in afterwards by
|
||||||
|
the background enrichment worker (one original-file download per photo -
|
||||||
|
Nextcloud has no metadata-only endpoint the way Immich does). The app
|
||||||
|
password is sent server-side only via the coordinator's image headers.
|
||||||
|
"""
|
||||||
|
from . import nextcloud as nc_api
|
||||||
|
|
||||||
|
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||||
|
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
|
||||||
|
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
|
||||||
|
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
|
||||||
|
recursive = bool(self.entry.data.get(CONF_NEXTCLOUD_RECURSIVE, False))
|
||||||
|
size = self.entry.data.get(
|
||||||
|
CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE
|
||||||
|
)
|
||||||
|
if not url or not username or not password:
|
||||||
|
raise UpdateFailed("Nextcloud provider is missing URL or credentials")
|
||||||
|
|
||||||
|
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
|
||||||
|
try:
|
||||||
|
photos = await client.async_list_photos(recursive=recursive)
|
||||||
|
except Exception as err:
|
||||||
|
raise UpdateFailed(f"Error listing Nextcloud folder: {err}") from err
|
||||||
|
|
||||||
|
if not photos:
|
||||||
|
raise UpdateFailed("No images found in the Nextcloud folder")
|
||||||
|
|
||||||
|
# The camera fetches image bytes server-side with this Basic-auth
|
||||||
|
# header, so the app password never appears in the browser URL.
|
||||||
|
self.image_request_headers = dict(client.image_headers)
|
||||||
|
|
||||||
|
items: list[MediaItem] = []
|
||||||
|
for p in photos:
|
||||||
|
href = p.get("href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
if size != NEXTCLOUD_IMAGE_ORIGINAL and p.get("file_id"):
|
||||||
|
display_url = nc_api.build_preview_url(
|
||||||
|
client.base_url, p["file_id"], NEXTCLOUD_PREVIEW_PX
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
display_url = href
|
||||||
|
items.append(
|
||||||
|
MediaItem(
|
||||||
|
url=display_url,
|
||||||
|
width=None,
|
||||||
|
height=None,
|
||||||
|
mime_type=p.get("content_type"),
|
||||||
|
filename=p.get("filename"),
|
||||||
|
uploaded_at=p.get("mtime_ms"),
|
||||||
|
byte_size=p.get("size"),
|
||||||
|
source_id=p.get("file_id") or href,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": self.entry.title,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _enrich_nextcloud_item(self, item: MediaItem) -> None:
|
||||||
|
"""Download one Nextcloud photo's original bytes and read its EXIF.
|
||||||
|
|
||||||
|
Nextcloud's WebDAV folder has no metadata-only endpoint (unlike
|
||||||
|
Immich's per-asset detail call), so enrichment costs one full-file
|
||||||
|
download per photo regardless of the display quality configured.
|
||||||
|
"""
|
||||||
|
from . import nextcloud as nc_api
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
url = self.entry.data.get(CONF_NEXTCLOUD_URL)
|
||||||
|
username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME)
|
||||||
|
password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD)
|
||||||
|
folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or ""
|
||||||
|
if not username or not password or not url:
|
||||||
|
item.exif_scanned = True
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reconstruct the original-file URL: for preview items the display url
|
||||||
|
# is the preview endpoint, so fall back to the folder href by filename.
|
||||||
|
original_url = None
|
||||||
|
if isinstance(item.url, str) and "/remote.php/dav/files/" in item.url:
|
||||||
|
original_url = item.url
|
||||||
|
elif item.filename:
|
||||||
|
client = nc_api.NextcloudClient(self.hass, url, username, password, folder)
|
||||||
|
original_url = client.dav_root + quote(item.filename)
|
||||||
|
if not original_url:
|
||||||
|
item.exif_scanned = True
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": nc_api.basic_auth_header(username, password)
|
||||||
|
}
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
try:
|
||||||
|
async with async_timeout.timeout(30):
|
||||||
|
async with session.get(original_url, headers=headers) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||||
|
total += len(chunk)
|
||||||
|
if total > _NEXTCLOUD_ENRICH_MAX_BYTES:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Nextcloud: %s exceeded %d byte enrichment cap; skipping",
|
||||||
|
item.filename, _NEXTCLOUD_ENRICH_MAX_BYTES,
|
||||||
|
)
|
||||||
|
item.exif_scanned = True
|
||||||
|
return
|
||||||
|
chunks.append(chunk)
|
||||||
|
data = b"".join(chunks)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Nextcloud: failed to download %s for enrichment: %s",
|
||||||
|
item.filename, err,
|
||||||
|
)
|
||||||
|
item.exif_scanned = True
|
||||||
|
return
|
||||||
|
|
||||||
|
info = await self.hass.async_add_executor_job(
|
||||||
|
_read_exif_from_bytes, data, item.uploaded_at
|
||||||
|
)
|
||||||
|
if "captured_at" in info:
|
||||||
|
item.captured_at = info["captured_at"]
|
||||||
|
if "description" in info:
|
||||||
|
item.description = info["description"]
|
||||||
|
if "latitude" in info and "longitude" in info:
|
||||||
|
item.latitude = info["latitude"]
|
||||||
|
item.longitude = info["longitude"]
|
||||||
|
item.exif_scanned = True
|
||||||
|
|
||||||
async def _enrich_immich_item(self, item: MediaItem) -> None:
|
async def _enrich_immich_item(self, item: MediaItem) -> None:
|
||||||
"""Fetch one Immich asset's detail and fill location/description."""
|
"""Fetch one Immich asset's detail and fill location/description."""
|
||||||
from . import immich as immich_api
|
from . import immich as immich_api
|
||||||
@@ -1677,6 +1944,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
self.async_set_updated_data(data)
|
self.async_set_updated_data(data)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if self.provider == PROVIDER_NEXTCLOUD:
|
||||||
|
try:
|
||||||
|
await self._enrich_nextcloud_item(item)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.debug("Nextcloud enrich error: %s", err)
|
||||||
|
item.exif_scanned = True
|
||||||
|
scanned_since_save += 1
|
||||||
|
self._enrich_progress["exif_done"] = (
|
||||||
|
self._enrich_progress.get("exif_done", 0) + 1
|
||||||
|
)
|
||||||
|
if scanned_since_save >= _EXIF_BATCH_SAVE:
|
||||||
|
scanned_since_save = 0
|
||||||
|
await self._save_cached_items(data)
|
||||||
|
self.async_set_updated_data(data)
|
||||||
|
continue
|
||||||
|
|
||||||
url = item.url
|
url = item.url
|
||||||
if not url.startswith("file://"):
|
if not url.startswith("file://"):
|
||||||
item.exif_scanned = True
|
item.exif_scanned = True
|
||||||
|
|||||||
@@ -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.5.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",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
"synology": {
|
"synology": {
|
||||||
"title": "Synology Photos",
|
"title": "Synology Photos",
|
||||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
|
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Choose Personal for your own photos and albums (this is also where albums shared with you appear); choose Shared Space only if your NAS has the shared team library enabled. Tip: use a dedicated Photos account rather than an admin login.",
|
||||||
"data": {
|
"data": {
|
||||||
"synology_url": "DSM URL",
|
"synology_url": "DSM URL",
|
||||||
"synology_username": "Username",
|
"synology_username": "Username",
|
||||||
@@ -98,12 +98,30 @@
|
|||||||
},
|
},
|
||||||
"synology_select": {
|
"synology_select": {
|
||||||
"title": "Synology source",
|
"title": "Synology source",
|
||||||
"description": "Pick an album or show the whole library, and choose the image quality.",
|
"description": "Choose what to show. Tick any mix of favorites, albums, people, places, tags and subjects; they are combined into one slideshow. Leave everything empty to show all photos in this space.",
|
||||||
"data": {
|
"data": {
|
||||||
"album_name": "Album name",
|
"album_name": "Album name",
|
||||||
"album": "Album",
|
"favorites": "Favorites",
|
||||||
|
"albums": "Albums",
|
||||||
|
"people": "People",
|
||||||
|
"places": "Places",
|
||||||
|
"tags": "Tags",
|
||||||
|
"subjects": "Subjects",
|
||||||
"synology_image_size": "Image quality"
|
"synology_image_size": "Image quality"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"nextcloud": {
|
||||||
|
"title": "Nextcloud folder",
|
||||||
|
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
|
||||||
|
"data": {
|
||||||
|
"album_name": "Album name",
|
||||||
|
"nextcloud_url": "Server URL",
|
||||||
|
"nextcloud_username": "Username",
|
||||||
|
"nextcloud_password": "App password",
|
||||||
|
"nextcloud_folder": "Folder path (optional)",
|
||||||
|
"nextcloud_recursive": "Include subfolders",
|
||||||
|
"nextcloud_image_size": "Image quality"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -122,13 +140,15 @@
|
|||||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
||||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
|
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.",
|
||||||
|
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
|
||||||
|
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Local Folder options",
|
"title": "Location & privacy options",
|
||||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||||
"data": {
|
"data": {
|
||||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
"synology": {
|
"synology": {
|
||||||
"title": "Synology Photos",
|
"title": "Synology Photos",
|
||||||
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
|
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Choose Personal for your own photos and albums (this is also where albums shared with you appear); choose Shared Space only if your NAS has the shared team library enabled. Tip: use a dedicated Photos account rather than an admin login.",
|
||||||
"data": {
|
"data": {
|
||||||
"synology_url": "DSM URL",
|
"synology_url": "DSM URL",
|
||||||
"synology_username": "Username",
|
"synology_username": "Username",
|
||||||
@@ -98,12 +98,30 @@
|
|||||||
},
|
},
|
||||||
"synology_select": {
|
"synology_select": {
|
||||||
"title": "Synology source",
|
"title": "Synology source",
|
||||||
"description": "Pick an album or show the whole library, and choose the image quality.",
|
"description": "Choose what to show. Tick any mix of favorites, albums, people, places, tags and subjects; they are combined into one slideshow. Leave everything empty to show all photos in this space.",
|
||||||
"data": {
|
"data": {
|
||||||
"album_name": "Album name",
|
"album_name": "Album name",
|
||||||
"album": "Album",
|
"favorites": "Favorites",
|
||||||
|
"albums": "Albums",
|
||||||
|
"people": "People",
|
||||||
|
"places": "Places",
|
||||||
|
"tags": "Tags",
|
||||||
|
"subjects": "Subjects",
|
||||||
"synology_image_size": "Image quality"
|
"synology_image_size": "Image quality"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"nextcloud": {
|
||||||
|
"title": "Nextcloud folder",
|
||||||
|
"description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.",
|
||||||
|
"data": {
|
||||||
|
"album_name": "Album name",
|
||||||
|
"nextcloud_url": "Server URL",
|
||||||
|
"nextcloud_username": "Username",
|
||||||
|
"nextcloud_password": "App password",
|
||||||
|
"nextcloud_folder": "Folder path (optional)",
|
||||||
|
"nextcloud_recursive": "Include subfolders",
|
||||||
|
"nextcloud_image_size": "Image quality"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -122,13 +140,15 @@
|
|||||||
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
|
||||||
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
|
||||||
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
|
||||||
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
|
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.",
|
||||||
|
"synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.",
|
||||||
|
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Local Folder options",
|
"title": "Location & privacy options",
|
||||||
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
"description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.",
|
||||||
"data": {
|
"data": {
|
||||||
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
"reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
* tap_action: none # none | more-info
|
* tap_action: none # none | more-info
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VERSION = "1.5.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": "Режим расширенных настроек отключен; доступны только основные параметры"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user