Compare commits
2009
Commits
2094c84c6a
..
main
+1
-1
@@ -1 +1 @@
|
|||||||
2026.7.4
|
2026.8.2
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -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": 72, "version": 1, "ha_version": "2026.7.4", "start_ts": 1785281939.8616378}
|
{"pid": 71, "version": 1, "ha_version": "2026.8.2", "start_ts": 1787238806.3870258}
|
||||||
@@ -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
|
||||||
@@ -196,6 +196,13 @@
|
|||||||
"vertical": false,
|
"vertical": false,
|
||||||
"features_position": "bottom"
|
"features_position": "bottom"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "light.3d_printer_lights",
|
||||||
|
"name": "Printer Light",
|
||||||
|
"vertical": false,
|
||||||
|
"features_position": "bottom"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "heading",
|
"type": "heading",
|
||||||
"heading": "Motion",
|
"heading": "Motion",
|
||||||
@@ -401,6 +408,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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,6 +119,33 @@
|
|||||||
"status_adaptive_color": false,
|
"status_adaptive_color": false,
|
||||||
"icon_adaptive_color": true
|
"icon_adaptive_color": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:uptime-card",
|
||||||
|
"entity": "sensor.octoprint_status",
|
||||||
|
"name": "NAS Availability",
|
||||||
|
"icon": "mdi:printer-3d",
|
||||||
|
"ok": [
|
||||||
|
"up",
|
||||||
|
"Up",
|
||||||
|
"online"
|
||||||
|
],
|
||||||
|
"ko": [
|
||||||
|
"down",
|
||||||
|
"Down",
|
||||||
|
"offline"
|
||||||
|
],
|
||||||
|
"alias": {
|
||||||
|
"ok": "Up",
|
||||||
|
"ko": "Down"
|
||||||
|
},
|
||||||
|
"show_state": true,
|
||||||
|
"bar": {
|
||||||
|
"amount": 30
|
||||||
|
},
|
||||||
|
"title_adaptive_color": true,
|
||||||
|
"status_adaptive_color": false,
|
||||||
|
"icon_adaptive_color": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "sensor",
|
"type": "sensor",
|
||||||
"entity": "sensor.nas_response_time",
|
"entity": "sensor.nas_response_time",
|
||||||
@@ -133,6 +160,13 @@
|
|||||||
"graph": "line",
|
"graph": "line",
|
||||||
"hours_to_show": 24
|
"hours_to_show": 24
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"graph": "line",
|
||||||
|
"type": "sensor",
|
||||||
|
"entity": "sensor.octoprint_response_time",
|
||||||
|
"hours_to_show": 24,
|
||||||
|
"detail": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"graph": "line",
|
"graph": "line",
|
||||||
"type": "sensor",
|
"type": "sensor",
|
||||||
@@ -154,6 +188,13 @@
|
|||||||
"hours_to_show": 24,
|
"hours_to_show": 24,
|
||||||
"detail": 1
|
"detail": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"graph": "line",
|
||||||
|
"type": "sensor",
|
||||||
|
"entity": "sensor.octoprint_certificate_expiry",
|
||||||
|
"hours_to_show": 24,
|
||||||
|
"detail": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"graph": "line",
|
"graph": "line",
|
||||||
"type": "sensor",
|
"type": "sensor",
|
||||||
@@ -304,6 +345,56 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,909 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"minor_version": 1,
|
||||||
|
"key": "lovelace.printer_stats",
|
||||||
|
"data": {
|
||||||
|
"config": {
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"title": "Print",
|
||||||
|
"path": "print",
|
||||||
|
"type": "sections",
|
||||||
|
"max_columns": 4,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Printer Status",
|
||||||
|
"icon": "mdi:printer-3d-nozzle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:webrtc-camera",
|
||||||
|
"url": "octoprint",
|
||||||
|
"mode": "mse",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "binary_sensor.octoprint_printing",
|
||||||
|
"name": "Printing",
|
||||||
|
"icon": "mdi:printer-3d",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "binary_sensor.octoprint_connected",
|
||||||
|
"name": "Connected",
|
||||||
|
"icon": "mdi:lan-connect",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_print_status",
|
||||||
|
"name": "Status",
|
||||||
|
"icon": "mdi:state-machine",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_last_event",
|
||||||
|
"name": "Last Event",
|
||||||
|
"icon": "mdi:history",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "binary_sensor.octoprint_paused",
|
||||||
|
"name": "Paused",
|
||||||
|
"icon": "mdi:pause",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "markdown",
|
||||||
|
"content": "**File:** {{ states('sensor.octoprint_print_file') }}\n**Size:** {{ (states('sensor.octoprint_print_file_size') | int(0) / 1048576) | round(2) }} MB\n**User:** {{ states('sensor.octoprint_print_user') }}",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_print_file",
|
||||||
|
"name": "File",
|
||||||
|
"icon": "mdi:file-code",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Print Progress",
|
||||||
|
"icon": "mdi:progress-clock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gauge",
|
||||||
|
"entity": "sensor.octoprint_print_progress_live",
|
||||||
|
"name": "Progress",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"unit": "%",
|
||||||
|
"severity": {
|
||||||
|
"green": 0,
|
||||||
|
"yellow": 40,
|
||||||
|
"red": 90
|
||||||
|
},
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6,
|
||||||
|
"rows": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:mushroom-template-card",
|
||||||
|
"primary": "{% set s = states('sensor.octoprint_elapsed') | float(0) %}{% set h = (s / 3600) | int %}{% set m = ((s % 3600) / 60) | int %}Elapsed: {% if h %}{{ h }} hr {{ m }} min{% else %}{{ m }} min{% endif %}",
|
||||||
|
"name": "Elapsed",
|
||||||
|
"icon": "mdi:clock-start",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:mushroom-template-card",
|
||||||
|
"primary": "{% set s = states('sensor.octoprint_time_left') | float(-1) %}{% if s <= 0 %}Remaining: No estimate{% else %}{% set h = (s / 3600) | int %}{% set m = ((s % 3600) / 60) | int %}Remain: {% if h %}{{ h }} hr {{ m }} min{% else %}{{ m }} min{% endif %}{% endif %}",
|
||||||
|
"name": "Remaining",
|
||||||
|
"icon": "mdi:clock-end",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "markdown",
|
||||||
|
"content": "{% set finish = states('sensor.octoprint_approximate_completion_time') | as_datetime %}{% if finish %}<ha-icon icon='mdi:clock-outline'></ha-icon> **Est. finish:** {{ (as_local(finish)).strftime('%I:%M %p').lstrip('0') }}{% else %}<ha-icon icon='mdi:clock-outline'></ha-icon> **Est. finish:** —{% endif %}",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_current_z",
|
||||||
|
"name": "Current Z",
|
||||||
|
"icon": "mdi:axis-z-arrow",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "light.3d_printer_lights",
|
||||||
|
"name": "Printer Light",
|
||||||
|
"icon": "mdi:lightbulb",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sensor",
|
||||||
|
"entity": "sensor.octoprint_print_progress_live",
|
||||||
|
"name": "Progress History",
|
||||||
|
"graph": "line",
|
||||||
|
"hours_to_show": 24,
|
||||||
|
"detail": 1,
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Temperatures",
|
||||||
|
"icon": "mdi:thermometer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gauge",
|
||||||
|
"entity": "sensor.octoprint_tool_0_temperature",
|
||||||
|
"name": "Nozzle",
|
||||||
|
"min": 0,
|
||||||
|
"max": 260,
|
||||||
|
"unit": "°C",
|
||||||
|
"severity": {
|
||||||
|
"green": 0,
|
||||||
|
"yellow": 180,
|
||||||
|
"red": 240
|
||||||
|
},
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gauge",
|
||||||
|
"entity": "sensor.octoprint_bed_temperature",
|
||||||
|
"name": "Bed",
|
||||||
|
"min": 0,
|
||||||
|
"max": 120,
|
||||||
|
"unit": "°C",
|
||||||
|
"severity": {
|
||||||
|
"green": 0,
|
||||||
|
"yellow": 60,
|
||||||
|
"red": 100
|
||||||
|
},
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gauge",
|
||||||
|
"entity": "sensor.octoprint_soc_temperature",
|
||||||
|
"name": "SoC",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"unit": "°C",
|
||||||
|
"severity": {
|
||||||
|
"green": 0,
|
||||||
|
"yellow": 70,
|
||||||
|
"red": 90
|
||||||
|
},
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_tool_0_target",
|
||||||
|
"name": "Nozzle Target",
|
||||||
|
"icon": "mdi:printer-3d-nozzle",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_bed_target",
|
||||||
|
"name": "Bed Target",
|
||||||
|
"icon": "mdi:radiator",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nozzle Temperature",
|
||||||
|
"type": "history-graph",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_tool_0_temperature",
|
||||||
|
"name": "Actual",
|
||||||
|
"color": "#ef5350"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_tool_0_target",
|
||||||
|
"name": "Target",
|
||||||
|
"color": "#4caf50"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hours_to_show": 24,
|
||||||
|
"show_names": true,
|
||||||
|
"expand_legend": true,
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Bed Temperature",
|
||||||
|
"type": "history-graph",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_bed_temperature",
|
||||||
|
"name": "Actual",
|
||||||
|
"color": "#42a5f5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_bed_target",
|
||||||
|
"name": "Target",
|
||||||
|
"color": "#4caf50"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hours_to_show": 24,
|
||||||
|
"show_names": true,
|
||||||
|
"expand_legend": true,
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "button",
|
||||||
|
"name": "Extrude 10mm",
|
||||||
|
"icon": "mdi:arrow-expand-up",
|
||||||
|
"tap_action": {
|
||||||
|
"action": "perform-action",
|
||||||
|
"perform_action": "rest_command.octoprint_extrude_10"
|
||||||
|
},
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Totals",
|
||||||
|
"icon": "mdi:chart-box-outline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_printer_print_time_today",
|
||||||
|
"name": "Print Time Today",
|
||||||
|
"icon": "mdi:clock-start",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_printer_print_time_this_week",
|
||||||
|
"name": "Print Time This Week",
|
||||||
|
"icon": "mdi:calendar-week",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
},
|
||||||
|
"entity": "sensor.octoprint_printer_print_time_last_7_days",
|
||||||
|
"name": "Print Time Last 7 Days",
|
||||||
|
"icon": "mdi:history",
|
||||||
|
"vertical": false,
|
||||||
|
"features_position": "bottom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_printer_prints_today",
|
||||||
|
"name": "Prints Today",
|
||||||
|
"icon": "mdi:counter",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_printer_prints_this_week",
|
||||||
|
"name": "Prints This Week",
|
||||||
|
"icon": "mdi:counter",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_printer_prints_last_7_days",
|
||||||
|
"name": "Prints Last 7 Days",
|
||||||
|
"icon": "mdi:counter",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Filament Usage",
|
||||||
|
"icon": "mdi:chart-donut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_last_print_filament",
|
||||||
|
"name": "Last Job",
|
||||||
|
"icon": "mdi:history",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_filament_daily",
|
||||||
|
"name": "Filament Today",
|
||||||
|
"icon": "mdi:chart-donut",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_filament_weekly",
|
||||||
|
"name": "Filament This Week",
|
||||||
|
"icon": "mdi:calendar-week",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_filament_monthly",
|
||||||
|
"name": "Filament This Month",
|
||||||
|
"icon": "mdi:calendar-month",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_filament_used_total",
|
||||||
|
"name": "Grand Total",
|
||||||
|
"icon": "mdi:chart-pie",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "History",
|
||||||
|
"path": "history",
|
||||||
|
"type": "sections",
|
||||||
|
"max_columns": 2,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Temperature History",
|
||||||
|
"icon": "mdi:chart-line-variant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "history-graph",
|
||||||
|
"title": "Temperatures (24h)",
|
||||||
|
"hours_to_show": 24,
|
||||||
|
"show_names": true,
|
||||||
|
"expand_legend": true,
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_tool_0_temperature",
|
||||||
|
"name": "Nozzle",
|
||||||
|
"color": "#ef5350"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_tool_0_target",
|
||||||
|
"name": "Nozzle Target",
|
||||||
|
"color": "#ffab91"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_bed_temperature",
|
||||||
|
"name": "Bed",
|
||||||
|
"color": "#42a5f5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_bed_target",
|
||||||
|
"name": "Bed Target",
|
||||||
|
"color": "#90caf9"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "history-graph",
|
||||||
|
"title": "Temperatures (7 days)",
|
||||||
|
"hours_to_show": 168,
|
||||||
|
"show_names": true,
|
||||||
|
"expand_legend": true,
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_tool_0_temperature",
|
||||||
|
"name": "Nozzle",
|
||||||
|
"color": "#ef5350"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_bed_temperature",
|
||||||
|
"name": "Bed",
|
||||||
|
"color": "#42a5f5"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Print Activity",
|
||||||
|
"icon": "mdi:chart-box-outline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:apexcharts-card",
|
||||||
|
"header": {
|
||||||
|
"show": true,
|
||||||
|
"title": "Z Position (24h)",
|
||||||
|
"show_states": true,
|
||||||
|
"colorize_states": true
|
||||||
|
},
|
||||||
|
"graph_span": "24h",
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_current_z",
|
||||||
|
"name": "Current Z",
|
||||||
|
"color": "#ab47bc",
|
||||||
|
"type": "line",
|
||||||
|
"curve": "straight",
|
||||||
|
"fill_raw": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
},
|
||||||
|
"apex_config": {
|
||||||
|
"chart": {
|
||||||
|
"height": 300
|
||||||
|
},
|
||||||
|
"stroke": {
|
||||||
|
"width": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "history-graph",
|
||||||
|
"title": "Print Progress (24h)",
|
||||||
|
"hours_to_show": 24,
|
||||||
|
"show_names": true,
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_print_progress_live",
|
||||||
|
"name": "Progress",
|
||||||
|
"color": "#66bb6a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "history-graph",
|
||||||
|
"title": "Print Duration (24h)",
|
||||||
|
"hours_to_show": 24,
|
||||||
|
"show_names": true,
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_elapsed",
|
||||||
|
"name": "Elapsed",
|
||||||
|
"color": "#26c6da"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_time_left",
|
||||||
|
"name": "Remaining",
|
||||||
|
"color": "#ffa726"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Print History",
|
||||||
|
"icon": "mdi:history"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "markdown",
|
||||||
|
"content": "**Successful:** {{ states('sensor.octoprint_successful_prints_count') }} | **Failed:** {{ states('sensor.octoprint_failed_prints_count') }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "history-graph",
|
||||||
|
"title": "Printing State (7 days)",
|
||||||
|
"hours_to_show": 168,
|
||||||
|
"show_names": true,
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"entity": "binary_sensor.octoprint_printing",
|
||||||
|
"name": "Printing",
|
||||||
|
"color": "#66bb6a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:apexcharts-card",
|
||||||
|
"header": {
|
||||||
|
"show": true,
|
||||||
|
"title": "Print Outcomes (30 days)",
|
||||||
|
"show_states": true,
|
||||||
|
"colorize_states": true
|
||||||
|
},
|
||||||
|
"graph_span": "30d",
|
||||||
|
"apex_config": {
|
||||||
|
"chart": {
|
||||||
|
"type": "bar",
|
||||||
|
"stacked": true,
|
||||||
|
"height": 300
|
||||||
|
},
|
||||||
|
"plotOptions": {
|
||||||
|
"bar": {
|
||||||
|
"columnWidth": "50%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dataLabels": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"yaxis": {
|
||||||
|
"tickAmount": 4,
|
||||||
|
"labels": {
|
||||||
|
"minWidth": 40
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_successful_prints_count",
|
||||||
|
"name": "Successful",
|
||||||
|
"color": "#66bb6a",
|
||||||
|
"type": "column",
|
||||||
|
"group_by": {
|
||||||
|
"func": "diff",
|
||||||
|
"duration": "1d",
|
||||||
|
"start_with_last": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_failed_prints_count",
|
||||||
|
"name": "Failed",
|
||||||
|
"color": "#ef5350",
|
||||||
|
"type": "column",
|
||||||
|
"group_by": {
|
||||||
|
"func": "diff",
|
||||||
|
"duration": "1d",
|
||||||
|
"start_with_last": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
},
|
||||||
|
"span": {
|
||||||
|
"end": "day"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "markdown",
|
||||||
|
"title": "Recent Prints",
|
||||||
|
"content": "{% for j in (state_attr('sensor.octoprint_recent_prints', 'allPrintJobs') | default([]))[:5] %}\n{% set d = j.get('duration', 0) | int %}\n- **{{ j.fileName }}** — {{ j.printStatusResult | title }}, {{ (d / 3600) | int }}h {{ ((d % 3600) / 60) | int }}m, {{ j.get('filamentModels', {}).get('total', {}).get('usedLength', 0) | round(0) }}mm\n{% endfor %}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "Filament Usage",
|
||||||
|
"icon": "mdi:spool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom:apexcharts-card",
|
||||||
|
"header": {
|
||||||
|
"show": true,
|
||||||
|
"title": "Filament Usage (30 days)",
|
||||||
|
"show_states": true,
|
||||||
|
"colorize_states": true
|
||||||
|
},
|
||||||
|
"graph_span": "30d",
|
||||||
|
"apex_config": {
|
||||||
|
"chart": {
|
||||||
|
"type": "bar",
|
||||||
|
"height": 300
|
||||||
|
},
|
||||||
|
"plotOptions": {
|
||||||
|
"bar": {
|
||||||
|
"columnWidth": "50%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dataLabels": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"yaxis": {
|
||||||
|
"tickAmount": 4,
|
||||||
|
"labels": {
|
||||||
|
"minWidth": 40
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_filament_daily",
|
||||||
|
"name": "Used (mm)",
|
||||||
|
"color": "#26c6da",
|
||||||
|
"type": "column",
|
||||||
|
"group_by": {
|
||||||
|
"func": "last",
|
||||||
|
"duration": "1d",
|
||||||
|
"start_with_last": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_filament_used_total",
|
||||||
|
"name": "Total (mm)",
|
||||||
|
"color": "#ab47bc",
|
||||||
|
"type": "line",
|
||||||
|
"curve": "straight",
|
||||||
|
"group_by": {
|
||||||
|
"func": "last",
|
||||||
|
"duration": "1d",
|
||||||
|
"start_with_last": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "sensor.octoprint_filament_weekly",
|
||||||
|
"name": "Weekly (mm)",
|
||||||
|
"color": "#ffca28",
|
||||||
|
"type": "column",
|
||||||
|
"group_by": {
|
||||||
|
"func": "last",
|
||||||
|
"duration": "1d",
|
||||||
|
"start_with_last": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_options": {
|
||||||
|
"columns": "full"
|
||||||
|
},
|
||||||
|
"span": {
|
||||||
|
"end": "day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "grid",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"heading": "OctoPrint Host",
|
||||||
|
"icon": "mdi:server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_disk_usage",
|
||||||
|
"icon": "mdi:harddisk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_cpu_usage",
|
||||||
|
"icon": "mdi:cpu-64-bit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_memory_usage",
|
||||||
|
"icon": "mdi:memory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_core_0_temperature",
|
||||||
|
"icon": "mdi:thermometer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_uptime",
|
||||||
|
"icon": "mdi:clock-outline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "sensor.octoprint_serial_health_ratio",
|
||||||
|
"name": "Serial Health",
|
||||||
|
"icon": "mdi:heart-pulse",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tile",
|
||||||
|
"entity": "binary_sensor.octoprint_serial_health_critical",
|
||||||
|
"name": "Serial Critical",
|
||||||
|
"icon": "mdi:alert",
|
||||||
|
"grid_options": {
|
||||||
|
"columns": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,15 @@
|
|||||||
"require_admin": false,
|
"require_admin": false,
|
||||||
"mode": "storage",
|
"mode": "storage",
|
||||||
"url_path": "dashboard-music"
|
"url_path": "dashboard-music"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "printer_stats",
|
||||||
|
"url_path": "printer-stats",
|
||||||
|
"title": "3D Printer",
|
||||||
|
"require_admin": false,
|
||||||
|
"show_in_sidebar": true,
|
||||||
|
"icon": "mdi:printer-3d-nozzle",
|
||||||
|
"mode": "storage"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-34
@@ -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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -26,12 +26,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ee7be1b18ae84060a5d515640f6e5739",
|
"id": "ee7be1b18ae84060a5d515640f6e5739",
|
||||||
"url": "/seatemperatures_frontend/sea-temperatures-card.js?v=3.1.0",
|
"url": "/seatemperatures_frontend/sea-temperatures-card.js?v=3.2.0",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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=939311749400",
|
||||||
"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=1787063719",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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.2.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=1789210374102",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -256,12 +256,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "8713ea9c008e4270bb28085eed5be959",
|
"id": "8713ea9c008e4270bb28085eed5be959",
|
||||||
"url": "/hacsfiles/Statistics-Graph-Chart-Card/statistics-graph-chart-card.js?hacstag=1034932856331",
|
"url": "/hacsfiles/Statistics-Graph-Chart-Card/statistics-graph-chart-card.js?hacstag=1034932856402",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
||||||
"url": "/taskmate/taskmate-routine-card.js?v=5.0.1",
|
"url": "/taskmate/taskmate-routine-card.js?v=5.2.0",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -290,12 +290,32 @@
|
|||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d933df59cb314d99a8f1a8d6fdd2b145",
|
"id": "9d164a6504b2427ba34497d5bf035fb4",
|
||||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.6.2",
|
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "132e3ebfb9f14af58f1b425056059373",
|
"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": "422bfc05badc4909bf9edd923411071c",
|
||||||
|
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "49ce062761fd4712932925d0e3b24d93",
|
||||||
"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,340 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
## Safety Guidelines
|
||||||
|
|
||||||
|
- NEVER expose or display contents of `secrets.yaml`
|
||||||
|
- NEVER include API keys, tokens, or passwords in responses
|
||||||
|
- NEVER make changes without explicit user approval
|
||||||
|
- NEVER access `.storage/`, `.cloud/`, or other internal directories
|
||||||
|
- NEVER attempt to modify Home Assistant's internal databases or registries
|
||||||
|
- NEVER parse internal JSON files for entity/device/area information
|
||||||
|
- ALWAYS prefer MCP tools for querying runtime state over internal file access
|
||||||
|
- ALWAYS use `call_service` through MCP rather than modifying state files
|
||||||
|
- WARN users before changes that require restart vs reload
|
||||||
|
- SUGGEST backing up files before major modifications
|
||||||
|
- CHECK configuration validity when possible
|
||||||
|
- ALWAYS confirm with user before writing, editing, or deleting any file
|
||||||
|
|
||||||
|
## RESTRICTED: Internal Home Assistant Directories
|
||||||
|
|
||||||
|
**NEVER read, modify, or directly interact with these internal directories:**
|
||||||
|
|
||||||
|
| Directory | Contains | Use Instead |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `.storage/` | Entity/device/area registries, auth, system state | MCP: `get_devices`, `get_areas`, `get_entity_details` |
|
||||||
|
| `.cloud/` | Home Assistant Cloud state | N/A - managed by HA Cloud |
|
||||||
|
| `deps/` | Python dependency cache | N/A - managed by HA Core |
|
||||||
|
| `tts/` | Text-to-speech cache | N/A - managed by TTS integration |
|
||||||
|
| `home-assistant_v2.db` | History SQLite database | MCP: `get_history`, `get_logbook` |
|
||||||
|
| `home-assistant.log` | Raw system logs | MCP: `get_error_log` |
|
||||||
|
|
||||||
|
These contain internal Home Assistant state that:
|
||||||
|
|
||||||
|
1. Is managed exclusively by Home Assistant core
|
||||||
|
2. Can corrupt your installation if modified incorrectly
|
||||||
|
3. May be overwritten by Home Assistant at any time
|
||||||
|
4. Has no stable schema or format guarantees
|
||||||
|
|
||||||
|
**For information that seems to require internal access, there is always a proper alternative:**
|
||||||
|
|
||||||
|
- Need entity details? -> Read configuration files OR use `get_entity_details`
|
||||||
|
- Need device info? -> Use `get_devices` MCP tool
|
||||||
|
- Need to check history? -> Use `get_history` MCP tool
|
||||||
|
- Need to see errors? -> Use `get_error_log` MCP tool
|
||||||
|
|
||||||
|
## Environment Context
|
||||||
|
|
||||||
|
- You are running inside the OpenCode app
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
## Skills: where the detailed procedures live
|
||||||
|
|
||||||
|
The add-on ships skills that hold the full procedure for each kind of Home
|
||||||
|
Assistant work. They are loaded on demand with the `skill` tool, so they cost
|
||||||
|
nothing until the task needs them. **Load the matching skill before you start** —
|
||||||
|
each one carries current syntax, the tool to prefer, and the mistakes worth
|
||||||
|
avoiding, none of which is repeated here.
|
||||||
|
|
||||||
|
| Load this skill | When the request is about |
|
||||||
|
|---|---|
|
||||||
|
| `home-assistant-configuration` | Writing or changing YAML: automations, scripts, scenes, templates, integrations, packages, helpers. Also validation, backups, and whether a change needs a reload or a restart. |
|
||||||
|
| `home-assistant-troubleshooting` | Something is broken, missing, unavailable, or behaving oddly. Bounded diagnosis that ends in a recommendation, not a fix. |
|
||||||
|
| `home-assistant-dashboard-ui` | Lovelace dashboards, views, cards, badges, themes, and screenshot verification of the result. |
|
||||||
|
| `home-assistant-zigbee-esphome` | Zigbee/ZHA/Z2M devices, cascade renames, stale-device cleanup, mesh maps, ESPHome, and device firmware updates. |
|
||||||
|
| `home-assistant-development` | Writing code rather than configuration: custom integrations, add-ons, native `llm.py` tool providers, MCP servers. |
|
||||||
|
|
||||||
|
More than one can apply — diagnose with the troubleshooting skill, then load the
|
||||||
|
configuration skill when the user approves a fix. The consent, scope, secret and
|
||||||
|
internal-directory rules above are always in force and are never relaxed by a
|
||||||
|
skill.
|
||||||
|
|
||||||
|
## Home Context
|
||||||
|
|
||||||
|
The add-on assembles context about *this specific installation* and loads it before the user's first message. You do not need to fetch any of it. Depending on the user's settings, some or all of these are present:
|
||||||
|
|
||||||
|
- **Install briefing** — a generated snapshot: Home Assistant version, areas, entity counts per domain, how the configuration is split up, which custom components are installed. It is orientation, **not live state** — re-check anything current with the MCP tools or `hab`. It may be absent or partial when Home Assistant was still starting.
|
||||||
|
- **`AGENTS.local.md`** — the user's own standing instructions, if they created that file. Follow them. This file (`AGENTS.md`) takes precedence where the two conflict, and the consent and safety rules above are never overridden.
|
||||||
|
- **Decision notes** — decisions the user has confirmed about their setup, injected as a short digest.
|
||||||
|
|
||||||
|
### Decision Notes
|
||||||
|
|
||||||
|
Decision notes record *why* an installation is the way it is. That reasoning cannot be recovered by re-reading the YAML, which is exactly why it is worth storing.
|
||||||
|
|
||||||
|
**When a request conflicts with a note**, say so before acting. Never silently reverse a recorded decision — tell the user which note applies and ask whether they want to change it.
|
||||||
|
|
||||||
|
**The digest is a summary, not the whole record.** It states how many active notes it is showing; when that is fewer than the total, the notes it left out are still in force. `recall_decisions` is the authority. Before changing something that looks deliberate, odd, or redundant — an inverted switch, a disabled integration, a duplicate-looking entity — check there first. An empty search result means *that query* found nothing, never that nothing was decided; search again in different words, or with no query at all, before concluding a thing is safe to "fix".
|
||||||
|
|
||||||
|
**To read more**, use `recall_decisions`. The injected digest carries only the decisions themselves; the rationale and the superseded history are retrieved on demand. Check it when a note looks relevant but you need the reasoning, or when the user asks what was decided before.
|
||||||
|
|
||||||
|
**To record**, offer first and then wait. Say what you would store, in the words you would store it, and call `remember_decision` with `user_approved: true` only after the user agrees. A general instruction to "remember this" for the current task is not approval to write a permanent note; asking costs one sentence.
|
||||||
|
|
||||||
|
Worth recording:
|
||||||
|
|
||||||
|
- Deliberate removals and disables ("that integration was removed because it fought with X")
|
||||||
|
- Intentional deviations from the obvious approach, and why
|
||||||
|
- Things to leave alone
|
||||||
|
- Constraints that will still be true in six months
|
||||||
|
|
||||||
|
Not worth recording — do not write these:
|
||||||
|
|
||||||
|
- What you did this session, or how you troubleshot something. **This is not a session log.**
|
||||||
|
- Anything already readable from the configuration files
|
||||||
|
- Anything the user has not explicitly approved
|
||||||
|
|
||||||
|
**Pinning** (`pin: true`) keeps a note in the digest when older notes stop fitting. It is for the small number of constraints where being forgotten causes real damage — something deliberately removed, something that must be left alone. Ask for the pin as well as for the note, and use it rarely: pinning everything pins nothing.
|
||||||
|
|
||||||
|
**Never** put passwords, tokens, or any value from `secrets.yaml` into a note. The tool rejects them, and a note is sent to the model in every future session.
|
||||||
|
|
||||||
|
## Home Assistant Interaction Model
|
||||||
|
|
||||||
|
Four ways to interact, each with a job the others do badly.
|
||||||
|
|
||||||
|
### 1. Configuration files (YAML)
|
||||||
|
|
||||||
|
The source of truth for defined behaviour: automations, scripts, scenes,
|
||||||
|
blueprints, integrations, templates, packages, customizations, and YAML-mode
|
||||||
|
dashboards. These files are designed for editing. Load
|
||||||
|
`home-assistant-configuration` before changing one — it carries the mandatory
|
||||||
|
style guide, the safe-write path, and the reload/restart rules.
|
||||||
|
|
||||||
|
### 2. MCP tools (runtime API)
|
||||||
|
|
||||||
|
Real-time interaction with the running instance:
|
||||||
|
|
||||||
|
- `get_states`, `search_entities`, `get_entity_details`, `get_home_context` — current state and compact area/domain/entity context. Prefer `get_home_context` over broad state dumps.
|
||||||
|
- `call_service` — control devices (with confirmation), and read from services that answer with data (`recorder.get_statistics`, `weather.get_forecasts`, `calendar.get_events`, `todo.get_items`); the response comes back automatically
|
||||||
|
- `get_history`, `get_logbook`, `get_calendar_events` — historical and calendar data; supplied timestamps must include `Z` or a UTC offset
|
||||||
|
- `get_devices`, `get_areas` — device and area registry
|
||||||
|
- `write_config_safe`, `validate_config`, `check_config_syntax` — safe config writing with validation, content protection and backup
|
||||||
|
- `get_integration_docs`, `get_breaking_changes` — current syntax, before writing any integration configuration
|
||||||
|
- `diagnose_entity`, `get_error_log`, `detect_anomalies`, `get_suggestions` — diagnosis
|
||||||
|
- `get_supervisor_health`, `get_supervisor_resolution`, `get_backup_posture`, `get_store_audit`, `get_supervisor_metrics`, `get_support_logs` — bounded, credential-redacted system evidence
|
||||||
|
- `remember_decision`, `recall_decisions`, `supersede_decision` — decision notes
|
||||||
|
- `watch_firmware_update`, `get_available_updates`, `update_component` — updates
|
||||||
|
- `screenshot_url` — visual verification (requires the `screenshot_enabled` option)
|
||||||
|
- `get_agent_capabilities`, `get_ha_llm_development_guide` — capability and native-LLM development information
|
||||||
|
|
||||||
|
Which tools exist depends on the add-on's MCP tool profile. If a tool you expect
|
||||||
|
is missing, the profile is reduced — say so instead of working around it.
|
||||||
|
|
||||||
|
### 3. hab CLI (Home Assistant Builder)
|
||||||
|
|
||||||
|
A CLI designed for AI agents, pre-authenticated via the Supervisor token. It is
|
||||||
|
the primary path for dashboards, areas/floors/zones/labels, helpers, scripts,
|
||||||
|
scenes, blueprints, backups, people, categories, to-do lists, notifications,
|
||||||
|
integrations, repairs, events and templates — the registry-level work that has
|
||||||
|
no YAML file behind it.
|
||||||
|
|
||||||
|
`hab` prints human-readable text by default; use `--json` for structured output.
|
||||||
|
Run `hab --help` or `hab <command> --help` for full usage.
|
||||||
|
|
||||||
|
<!-- HAB_LIVE_HELP_START -->
|
||||||
|
```
|
||||||
|
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 -->
|
||||||
|
|
||||||
|
### 4. zigporter CLI (Zigbee toolkit)
|
||||||
|
|
||||||
|
Zigbee device management, and the only tool here that **cascades a rename**
|
||||||
|
across automations, scripts, scenes and every Lovelace dashboard atomically.
|
||||||
|
`hab` renames one thing and leaves the references dangling. Also handles device
|
||||||
|
inspection across ZHA/Z2M/HA, stale-device cleanup, and mesh visualization.
|
||||||
|
|
||||||
|
Dry-run is the default for renames — always preview before `--apply`. The
|
||||||
|
`migrate` command is interactive and must NOT be used by an agent. Load
|
||||||
|
`home-assistant-zigbee-esphome` before any of this work.
|
||||||
|
|
||||||
|
<!-- ZIGPORTER_LIVE_HELP_START -->
|
||||||
|
```
|
||||||
|
|
||||||
|
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 -->
|
||||||
|
|
||||||
|
### Choosing between them
|
||||||
|
|
||||||
|
| Task | Use |
|
||||||
|
|---|---|
|
||||||
|
| Create/edit automations, scripts, scenes, templates | YAML + `write_config_safe` |
|
||||||
|
| Check current state, control a device | MCP (`get_home_context`, `call_service`) |
|
||||||
|
| Troubleshoot | MCP (`diagnose_entity`, `get_error_log`, `get_supervisor_health`) |
|
||||||
|
| Dashboards, areas, helpers, backups, blueprints, people | `hab` |
|
||||||
|
| Verify a UI change | `screenshot_url` |
|
||||||
|
| Rename an entity or device with all references | `zigporter rename-entity` / `rename-device` |
|
||||||
|
| Inspect a Zigbee device, map the mesh, clean up stale devices | `zigporter` |
|
||||||
|
| Device firmware updates | `watch_firmware_update` |
|
||||||
|
| Core/OS/Supervisor updates | `get_available_updates`, `update_component` |
|
||||||
|
|
||||||
|
### Internal directories
|
||||||
|
|
||||||
|
Home Assistant manages internal state in `.storage/` and friends. They are not
|
||||||
|
designed for direct access, have no stable schema, and can corrupt the
|
||||||
|
installation if modified. Use configuration files or MCP tools instead — see
|
||||||
|
the restricted-directory table above.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Downstairs Bathroom Light - Debug Session (2026-07-24)
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
Motion sensor triggers but bathroom light has a 20-30 second delay turning on.
|
||||||
|
|
||||||
|
## Key Entities
|
||||||
|
- `automation.downstairs_bathroom_light_contro` — automation (mode: restart, working correctly)
|
||||||
|
- `binary_sensor.downstairs_bathroom_occupancy` — Third Reality Zigbee motion sensor (Z2M)
|
||||||
|
- `switch.downstairs_bathroom_switch` — TP-Link HS200 (exposed via Homebridge)
|
||||||
|
- `light.bathroom_light` — TP-Link HS220 dimmer (separate device, NOT controlled by automation)
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
1. Automation logic is correct — triggers on occupancy on/off, 30-min timeout, mode restart
|
||||||
|
2. Automation IS firing and switch IS turning on (confirmed via history)
|
||||||
|
3. The delay is between automation trigger and switch responding — suggests Homebridge communication delay
|
||||||
|
4. No other HA automations reference this switch
|
||||||
|
5. User confirmed no HomeKit/TP-Link app automations interfering
|
||||||
|
6. User tried restarting Homebridge — delay persisted
|
||||||
|
7. User was about to restart entire HA server to test
|
||||||
|
|
||||||
|
## Resolution (2026-07-23)
|
||||||
|
Full HA server restart resolved the issue. Post-reboot trigger-to-action times are all under 0.5s.
|
||||||
|
The delay was caused by a stale Homebridge WebSocket connection to the TP-Link HS200.
|
||||||
|
|
||||||
|
## Next Steps If Issue Recurs
|
||||||
|
- Check Homebridge logs for TP-Link communication errors
|
||||||
|
- Consider switching automation to control `light.bathroom_light` (HS220) directly if TP-Link integration is available
|
||||||
|
- Check if Homebridge add-on needs update
|
||||||
+213
-8
@@ -2,7 +2,7 @@
|
|||||||
alias: 'Tablet: Dim Screen at Night'
|
alias: 'Tablet: Dim Screen at Night'
|
||||||
description: Drops tablet brightness to zero at night
|
description: Drops tablet brightness to zero at night
|
||||||
triggers:
|
triggers:
|
||||||
- at: 01:00:00
|
- at: '23:30:00'
|
||||||
trigger: time
|
trigger: time
|
||||||
actions:
|
actions:
|
||||||
- action: notify.mobile_app_sm_t387w
|
- action: notify.mobile_app_sm_t387w
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
metadata: {}
|
metadata: {}
|
||||||
target:
|
target:
|
||||||
device_id:
|
device_id:
|
||||||
- 3e67afd88a4b4d17552c8f370aaf40b5
|
|
||||||
- baad176ac2f415f3642e2d075bb6a977
|
- baad176ac2f415f3642e2d075bb6a977
|
||||||
data: {}
|
data: {}
|
||||||
mode: single
|
mode: single
|
||||||
@@ -223,6 +222,10 @@
|
|||||||
minutes: 0
|
minutes: 0
|
||||||
seconds: 0
|
seconds: 0
|
||||||
id: turn_off_logic
|
id: turn_off_logic
|
||||||
|
- trigger: state
|
||||||
|
entity_id: input_boolean.office_keep_lights_on
|
||||||
|
to: 'on'
|
||||||
|
id: keep_on_enabled
|
||||||
conditions: []
|
conditions: []
|
||||||
actions:
|
actions:
|
||||||
- choose:
|
- choose:
|
||||||
@@ -258,6 +261,9 @@
|
|||||||
- condition: state
|
- condition: state
|
||||||
entity_id: light.playroom_light
|
entity_id: light.playroom_light
|
||||||
state: 'on'
|
state: 'on'
|
||||||
|
- condition: state
|
||||||
|
entity_id: input_boolean.office_keep_lights_on
|
||||||
|
state: 'off'
|
||||||
sequence:
|
sequence:
|
||||||
- action: light.turn_off
|
- action: light.turn_off
|
||||||
target:
|
target:
|
||||||
@@ -267,6 +273,14 @@
|
|||||||
data:
|
data:
|
||||||
message: Office Lights Turned Off due to inactivity
|
message: Office Lights Turned Off due to inactivity
|
||||||
title: ⚡️ Office Lights
|
title: ⚡️ Office Lights
|
||||||
|
- conditions:
|
||||||
|
- condition: trigger
|
||||||
|
id: keep_on_enabled
|
||||||
|
sequence:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.playroom_light
|
||||||
|
data: {}
|
||||||
mode: restart
|
mode: restart
|
||||||
- id: '1781034724187'
|
- id: '1781034724187'
|
||||||
alias: 'Office: Turn Off Lights on Exit'
|
alias: 'Office: Turn Off Lights on Exit'
|
||||||
@@ -283,6 +297,9 @@
|
|||||||
- condition: template
|
- condition: template
|
||||||
value_template: '{{ states.binary_sensor.basement.last_changed > states.binary_sensor.office_door_contact.last_changed
|
value_template: '{{ states.binary_sensor.basement.last_changed > states.binary_sensor.office_door_contact.last_changed
|
||||||
}}'
|
}}'
|
||||||
|
- condition: state
|
||||||
|
entity_id: input_boolean.office_keep_lights_on
|
||||||
|
state: 'off'
|
||||||
actions:
|
actions:
|
||||||
- delay:
|
- delay:
|
||||||
minutes: 2
|
minutes: 2
|
||||||
@@ -712,12 +729,11 @@
|
|||||||
alias: Basement Temperature Change
|
alias: Basement Temperature Change
|
||||||
description: ''
|
description: ''
|
||||||
triggers:
|
triggers:
|
||||||
- type: temperature
|
- trigger: numeric_state
|
||||||
device_id: 3e67afd88a4b4d17552c8f370aaf40b5
|
entity_id: sensor.basement_temperature
|
||||||
entity_id: 64689573a8b11a83829efcae0022d7c1
|
|
||||||
domain: sensor
|
|
||||||
trigger: device
|
|
||||||
above: 15
|
above: 15
|
||||||
|
- trigger: numeric_state
|
||||||
|
entity_id: sensor.basement_temperature
|
||||||
below: 10
|
below: 10
|
||||||
conditions: []
|
conditions: []
|
||||||
actions:
|
actions:
|
||||||
@@ -2017,7 +2033,42 @@
|
|||||||
target:
|
target:
|
||||||
entity_id: input_boolean.away_mode
|
entity_id: input_boolean.away_mode
|
||||||
data: {}
|
data: {}
|
||||||
button2_double: []
|
button2_double:
|
||||||
|
- choose:
|
||||||
|
- conditions:
|
||||||
|
- condition: template
|
||||||
|
value_template: '{{ states(''input_text.office_lamp_previous_color'')
|
||||||
|
== '''' }}'
|
||||||
|
sequence:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.office_lamp
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.office_lamp_previous_color
|
||||||
|
data:
|
||||||
|
value: '{{ state_attr(''light.office_lamp'', ''rgb_color'') | join('','')
|
||||||
|
}}'
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.office_lamp
|
||||||
|
data:
|
||||||
|
rgb_color:
|
||||||
|
- 135
|
||||||
|
- 206
|
||||||
|
- 250
|
||||||
|
default:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.office_lamp
|
||||||
|
data:
|
||||||
|
rgb_color: '{{ states(''input_text.office_lamp_previous_color'').split('','')
|
||||||
|
| map(''int'') | list }}'
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.office_lamp_previous_color
|
||||||
|
data:
|
||||||
|
value: ''
|
||||||
button1_long_press: []
|
button1_long_press: []
|
||||||
button2_long_press: []
|
button2_long_press: []
|
||||||
- id: '1783972932942'
|
- id: '1783972932942'
|
||||||
@@ -3072,3 +3123,157 @@
|
|||||||
data:
|
data:
|
||||||
transition: 1
|
transition: 1
|
||||||
mode: single
|
mode: single
|
||||||
|
- id: '1785339249610'
|
||||||
|
alias: 'TEST: Nest Snapshot Capture'
|
||||||
|
description: Manual test for nest_snapshot.capture. Trigger from Developer Tools
|
||||||
|
after restart.
|
||||||
|
triggers: []
|
||||||
|
actions:
|
||||||
|
- action: nest_snapshot.capture
|
||||||
|
data:
|
||||||
|
entity_id: camera.front_door_front_door
|
||||||
|
filename: /media/nest_snapshot_test_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg
|
||||||
|
mode: single
|
||||||
|
- id: '1785688553432'
|
||||||
|
alias: 'Basement: Switch Toggles Living Room Lights'
|
||||||
|
description: Ikea BILRESA E2489 Dual Button (Basement Switch) toggles the Dining
|
||||||
|
Room Switch light on Button 1 single press. Button 2 single press dims to 30%
|
||||||
|
and restores the previous brightness.
|
||||||
|
use_blueprint:
|
||||||
|
path: censay/ikea-bilresa-e2489-matter-smart-button.yaml
|
||||||
|
input:
|
||||||
|
target_device: 2101b0e6d9ae7b4b2e1cb727a9ed42f0
|
||||||
|
button1_event: event.basement_switch_button_1
|
||||||
|
button2_event: event.basement_switch_button_2
|
||||||
|
button1_single:
|
||||||
|
- action: light.toggle
|
||||||
|
metadata: {}
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room_living_room_home_living_room
|
||||||
|
data: {}
|
||||||
|
button2_single:
|
||||||
|
- choose:
|
||||||
|
- conditions:
|
||||||
|
- condition: template
|
||||||
|
value_template: '{{ states(''input_text.living_room_previous_brightness'')
|
||||||
|
in ['''', ''unknown'', ''unavailable'', none] }}'
|
||||||
|
sequence:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room_living_room_home_living_room
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.living_room_previous_brightness
|
||||||
|
data:
|
||||||
|
value: '{{ state_attr(''light.living_room_living_room_home_living_room'',
|
||||||
|
''brightness'') }}'
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room_living_room_home_living_room
|
||||||
|
data:
|
||||||
|
brightness_pct: 30
|
||||||
|
default:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room_living_room_home_living_room
|
||||||
|
data:
|
||||||
|
brightness: '{{ states(''input_text.living_room_previous_brightness'')
|
||||||
|
| int }}'
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.living_room_previous_brightness
|
||||||
|
data:
|
||||||
|
value: ''
|
||||||
|
- id: '1786068561172'
|
||||||
|
alias: 3D Printer Running - Playroom Light & Basement Heat
|
||||||
|
description: When the 3D printer running helper turns on (print started), set the
|
||||||
|
3D printer lights to bright white, turn on the bedroom lamp, and set the basement
|
||||||
|
to 22 degrees C. When it turns off (print ended), set the 3D printer lights to
|
||||||
|
light blue at 25% and turn off the bedroom lamp after 10 minutes.
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: input_boolean.3d_printer_running
|
||||||
|
to: 'on'
|
||||||
|
id: printer_started
|
||||||
|
- trigger: state
|
||||||
|
entity_id: input_boolean.3d_printer_running
|
||||||
|
to: 'off'
|
||||||
|
id: printer_stopped
|
||||||
|
actions:
|
||||||
|
- choose:
|
||||||
|
- conditions:
|
||||||
|
- condition: trigger
|
||||||
|
id: printer_started
|
||||||
|
sequence:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.3d_printer_lights
|
||||||
|
data:
|
||||||
|
brightness_pct: 100
|
||||||
|
rgb_color:
|
||||||
|
- 255
|
||||||
|
- 255
|
||||||
|
- 255
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.bedroom_lamp
|
||||||
|
- action: climate.set_temperature
|
||||||
|
target:
|
||||||
|
entity_id: climate.basement
|
||||||
|
data:
|
||||||
|
temperature: 22
|
||||||
|
hvac_mode: heat
|
||||||
|
- conditions:
|
||||||
|
- condition: trigger
|
||||||
|
id: printer_stopped
|
||||||
|
sequence:
|
||||||
|
- action: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.3d_printer_lights
|
||||||
|
data:
|
||||||
|
brightness_pct: 25
|
||||||
|
rgb_color:
|
||||||
|
- 173
|
||||||
|
- 216
|
||||||
|
- 230
|
||||||
|
- delay:
|
||||||
|
minutes: 10
|
||||||
|
- action: light.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: light.bedroom_lamp
|
||||||
|
mode: restart
|
||||||
|
- id: '1786107704090'
|
||||||
|
alias: Count OctoPrint Print Outcomes
|
||||||
|
description: Increments counter.octoprint_successful_prints on PrintDone and counter.octoprint_failed_prints
|
||||||
|
on PrintFailed/PrintCancelled, triggered by sensor.octoprint_last_event state
|
||||||
|
changes.
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: sensor.octoprint_last_event
|
||||||
|
to: PrintDone
|
||||||
|
id: done
|
||||||
|
- trigger: state
|
||||||
|
entity_id: sensor.octoprint_last_event
|
||||||
|
to: PrintFailed
|
||||||
|
id: failed
|
||||||
|
- trigger: state
|
||||||
|
entity_id: sensor.octoprint_last_event
|
||||||
|
to: PrintCancelled
|
||||||
|
id: failed
|
||||||
|
actions:
|
||||||
|
- choose:
|
||||||
|
- conditions:
|
||||||
|
- condition: trigger
|
||||||
|
id: done
|
||||||
|
sequence:
|
||||||
|
- action: counter.increment
|
||||||
|
target:
|
||||||
|
entity_id: counter.octoprint_successful_prints
|
||||||
|
- conditions:
|
||||||
|
- condition: trigger
|
||||||
|
id: failed
|
||||||
|
sequence:
|
||||||
|
- action: counter.increment
|
||||||
|
target:
|
||||||
|
entity_id: counter.octoprint_failed_prints
|
||||||
|
mode: single
|
||||||
|
|||||||
+186
-6
@@ -1,6 +1,12 @@
|
|||||||
# 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:
|
||||||
|
|
||||||
@@ -8,6 +14,16 @@ utility_meter:
|
|||||||
shell_command:
|
shell_command:
|
||||||
cleanup_blink_snapshots: "find /media /config/www -name 'blink_*.jpg' -mmin +360 -delete"
|
cleanup_blink_snapshots: "find /media /config/www -name 'blink_*.jpg' -mmin +360 -delete"
|
||||||
|
|
||||||
|
# Rest Commands (OctoPrint nozzle extrusion)
|
||||||
|
rest_command:
|
||||||
|
octoprint_extrude_10:
|
||||||
|
url: "https://ender5pro.duckdns.org/api/printer/command"
|
||||||
|
method: POST
|
||||||
|
content_type: "application/json"
|
||||||
|
headers:
|
||||||
|
X-Api-Key: !secret octoprint_api_key
|
||||||
|
payload: '{"commands": ["G91", "G1 E10 F300", "G90"]}'
|
||||||
|
|
||||||
# Load ffmpeg
|
# Load ffmpeg
|
||||||
ffmpeg:
|
ffmpeg:
|
||||||
|
|
||||||
@@ -215,12 +231,15 @@ template:
|
|||||||
{% set kw = states('sensor.water_heater_power_draw') | float(0) %}
|
{% set kw = states('sensor.water_heater_power_draw') | float(0) %}
|
||||||
{{ (kw * 1000) | round(0) }}
|
{{ (kw * 1000) | round(0) }}
|
||||||
|
|
||||||
# HTTP Proxy Configuration
|
# OCTOPRINT: Most recent print's filament usage (mm), from PrintHistory plugin
|
||||||
http:
|
- name: "OctoPrint Last Print Filament"
|
||||||
use_x_forwarded_for: true
|
unique_id: octoprint_last_print_filament
|
||||||
trusted_proxies:
|
unit_of_measurement: "mm"
|
||||||
- 192.168.122.1
|
icon: mdi:spool
|
||||||
- 192.168.1.0/24
|
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:
|
||||||
@@ -244,6 +263,162 @@ 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"
|
||||||
|
- resource: "https://ender5pro.duckdns.org/api/job"
|
||||||
|
method: GET
|
||||||
|
headers:
|
||||||
|
X-Api-Key: !secret octoprint_api_key
|
||||||
|
scan_interval: 30
|
||||||
|
sensor:
|
||||||
|
- name: "OctoPrint Print Progress Live"
|
||||||
|
unique_id: octoprint_print_progress_live
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
state_class: measurement
|
||||||
|
icon: mdi:printer-3d-nozzle
|
||||||
|
value_template: >-
|
||||||
|
{{
|
||||||
|
value_json.progress.completion | float(0) | round(2)
|
||||||
|
}}
|
||||||
|
json_attributes_path: "$.progress"
|
||||||
|
json_attributes:
|
||||||
|
- "filepos"
|
||||||
|
- "printTime"
|
||||||
|
- "printTimeLeft"
|
||||||
|
- "printTimeLeftOrigin"
|
||||||
|
- name: "OctoPrint Print Time Left Live"
|
||||||
|
unique_id: octoprint_print_time_left_live
|
||||||
|
device_class: duration
|
||||||
|
unit_of_measurement: "s"
|
||||||
|
state_class: measurement
|
||||||
|
icon: mdi:timer-outline
|
||||||
|
value_template: >-
|
||||||
|
{{
|
||||||
|
value_json.progress.printTimeLeft | int(-1)
|
||||||
|
}}
|
||||||
|
- name: "OctoPrint Print State Live"
|
||||||
|
unique_id: octoprint_print_state_live
|
||||||
|
icon: mdi:printer-3d
|
||||||
|
value_template: "{{ value_json.state }}"
|
||||||
|
|
||||||
|
# OctoPrint physical position (holds last value when idle; raw discovery sensor zeroes on currentZ:null)
|
||||||
|
mqtt:
|
||||||
|
sensor:
|
||||||
|
- name: "Position Z"
|
||||||
|
unique_id: octoprint_position_z
|
||||||
|
state_topic: "octoPrint/event/PositionUpdate"
|
||||||
|
value_template: "{{ value_json.z }}"
|
||||||
|
unit_of_measurement: "mm"
|
||||||
|
device_class: distance
|
||||||
|
icon: mdi:axis-z-arrow
|
||||||
|
availability:
|
||||||
|
- topic: "octoPrint/mqtt"
|
||||||
|
payload_available: "connected"
|
||||||
|
payload_not_available: "disconnected"
|
||||||
|
device:
|
||||||
|
identifiers:
|
||||||
|
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||||
|
name: "OctoPrint"
|
||||||
|
|
||||||
|
- name: "Print File Size"
|
||||||
|
unique_id: octoprint_print_file_size
|
||||||
|
state_topic: "octoPrint/progress/printing"
|
||||||
|
value_template: "{{ value_json.printer_data.job.file.size | int(0) }}"
|
||||||
|
unit_of_measurement: "B"
|
||||||
|
device_class: data_size
|
||||||
|
icon: mdi:file-code
|
||||||
|
availability:
|
||||||
|
- topic: "octoPrint/mqtt"
|
||||||
|
payload_available: "connected"
|
||||||
|
payload_not_available: "disconnected"
|
||||||
|
device:
|
||||||
|
identifiers:
|
||||||
|
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||||
|
name: "OctoPrint"
|
||||||
|
|
||||||
|
- name: "Print User"
|
||||||
|
unique_id: octoprint_print_user
|
||||||
|
state_topic: "octoPrint/progress/printing"
|
||||||
|
value_template: "{{ value_json.printer_data.job.user | default('') }}"
|
||||||
|
icon: mdi:account
|
||||||
|
availability:
|
||||||
|
- topic: "octoPrint/mqtt"
|
||||||
|
payload_available: "connected"
|
||||||
|
payload_not_available: "disconnected"
|
||||||
|
device:
|
||||||
|
identifiers:
|
||||||
|
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||||
|
name: "OctoPrint"
|
||||||
|
|
||||||
|
- name: "Serial Health Ratio"
|
||||||
|
unique_id: octoprint_serial_health_ratio
|
||||||
|
state_topic: "octoPrint/progress/printing"
|
||||||
|
value_template: "{{ value_json.printer_data.health.ratio | float(0) }}"
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
icon: mdi:heart-pulse
|
||||||
|
availability:
|
||||||
|
- topic: "octoPrint/mqtt"
|
||||||
|
payload_available: "connected"
|
||||||
|
payload_not_available: "disconnected"
|
||||||
|
device:
|
||||||
|
identifiers:
|
||||||
|
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||||
|
name: "OctoPrint"
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
- name: "Serial Health Critical"
|
||||||
|
unique_id: octoprint_serial_health_critical
|
||||||
|
state_topic: "octoPrint/progress/printing"
|
||||||
|
value_template: "{{ 'ON' if value_json.printer_data.health.critical else 'OFF' }}"
|
||||||
|
device_class: problem
|
||||||
|
icon: mdi:alert
|
||||||
|
availability:
|
||||||
|
- topic: "octoPrint/mqtt"
|
||||||
|
payload_available: "connected"
|
||||||
|
payload_not_available: "disconnected"
|
||||||
|
device:
|
||||||
|
identifiers:
|
||||||
|
- "041acd1efce0452ca0a0360eeb89c3b4"
|
||||||
|
name: "OctoPrint"
|
||||||
|
|
||||||
emulated_hue:
|
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
|
||||||
@@ -253,3 +428,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,8 +382,11 @@ 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
|
||||||
|
):
|
||||||
|
# transition to a safe state, or to open while the sensor is
|
||||||
|
# allowed to be open — treat as initial state
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Initial state for %s is %s",
|
"Initial state for %s is %s",
|
||||||
entity,
|
entity,
|
||||||
@@ -386,6 +394,16 @@ class SensorHandler:
|
|||||||
)
|
)
|
||||||
self.update_ready_to_arm_status(sensor_config["area"])
|
self.update_ready_to_arm_status(sensor_config["area"])
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
# transition to a violation state — do not treat as initial,
|
||||||
|
# proceed through normal trigger evaluation
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Sensor %s recovered from unknown to %s while alarm is %s, "
|
||||||
|
"evaluating as live state change",
|
||||||
|
entity,
|
||||||
|
new_state,
|
||||||
|
self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]].state,
|
||||||
|
)
|
||||||
if old_state == new_state:
|
if old_state == new_state:
|
||||||
# not a state change - ignore
|
# not a state change - ignore
|
||||||
return
|
return
|
||||||
@@ -740,6 +758,13 @@ class SensorHandler:
|
|||||||
# Skip unknown sensors - they'll be handled when they become known
|
# Skip unknown sensors - they'll be handled when they become known
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if sensor_config[ATTR_ALLOW_OPEN] and sensor_state == STATE_OPEN:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Sensor %s is open with allow_open, skipping startup eval",
|
||||||
|
entity_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Check if sensor state is allowed in current alarm state
|
# Check if sensor state is allowed in current alarm state
|
||||||
res = sensor_state_allowed(sensor_state, sensor_config, alarm_entity.state)
|
res = sensor_state_allowed(sensor_state, sensor_config, alarm_entity.state)
|
||||||
|
|
||||||
|
|||||||
@@ -381,10 +381,10 @@ class AlarmoStorage:
|
|||||||
for area in data["areas"]:
|
for area in data["areas"]:
|
||||||
modes = {
|
modes = {
|
||||||
mode: ModeEntry(
|
mode: ModeEntry(
|
||||||
enabled=config["enabled"],
|
enabled=config.get("enabled", False),
|
||||||
exit_time=config["exit_time"],
|
exit_time=config.get("exit_time", None),
|
||||||
entry_time=config["entry_time"],
|
entry_time=config.get("entry_time", None),
|
||||||
trigger_time=config["trigger_time"],
|
trigger_time=config.get("trigger_time", None),
|
||||||
)
|
)
|
||||||
for (mode, config) in area["modes"].items()
|
for (mode, config) in area["modes"].items()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from .const import (
|
|||||||
CONF_ICLOUD_TOKEN,
|
CONF_ICLOUD_TOKEN,
|
||||||
CONF_ICLOUD_IMAGE_SIZE,
|
CONF_ICLOUD_IMAGE_SIZE,
|
||||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||||
|
CONF_ICLOUD_BACKEND,
|
||||||
ICLOUD_IMAGE_FULL,
|
ICLOUD_IMAGE_FULL,
|
||||||
ICLOUD_IMAGE_PREVIEW,
|
ICLOUD_IMAGE_PREVIEW,
|
||||||
CONF_SYNOLOGY_URL,
|
CONF_SYNOLOGY_URL,
|
||||||
@@ -219,7 +220,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if not ALBUM_URL_RE.match(url):
|
if not ALBUM_URL_RE.match(url):
|
||||||
errors[CONF_ALBUM_URL] = "invalid_album_url"
|
errors[CONF_ALBUM_URL] = "invalid_album_url"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}")
|
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_GOOGLE_SHARED}:{url}:{name}")
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=name,
|
title=name,
|
||||||
@@ -249,7 +250,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if not path:
|
if not path:
|
||||||
errors[CONF_LOCAL_PATH] = "invalid_path"
|
errors[CONF_LOCAL_PATH] = "invalid_path"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}")
|
await self.async_set_unique_id(f"{DOMAIN}:{PROVIDER_LOCAL_FOLDER}:{path}:{name}")
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=name,
|
title=name,
|
||||||
@@ -283,7 +284,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
|
errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(
|
await self.async_set_unique_id(
|
||||||
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}"
|
f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}:{name}"
|
||||||
)
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
@@ -395,7 +396,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
sel_id = json.dumps(selection, sort_keys=True)
|
sel_id = json.dumps(selection, sort_keys=True)
|
||||||
unique = (
|
unique = (
|
||||||
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
|
f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:"
|
||||||
f"composite:{sel_id}:{raw_filter}"
|
f"composite:{sel_id}:{raw_filter}:{name}"
|
||||||
)
|
)
|
||||||
await self.async_set_unique_id(unique)
|
await self.async_set_unique_id(unique)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
@@ -574,7 +575,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
sel_id = json.dumps(selection, sort_keys=True)
|
sel_id = json.dumps(selection, sort_keys=True)
|
||||||
unique = (
|
unique = (
|
||||||
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
|
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
|
||||||
f"composite:{sel_id}:{raw_filter}"
|
f"composite:{sel_id}:{raw_filter}:{name}"
|
||||||
)
|
)
|
||||||
await self.async_set_unique_id(unique)
|
await self.async_set_unique_id(unique)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
@@ -659,9 +660,13 @@ 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:
|
||||||
|
token, backend = parsed
|
||||||
|
if backend == icloud_api.BACKEND_CLOUDKIT:
|
||||||
|
client = icloud_api.IcloudCloudKitClient(self.hass, token)
|
||||||
else:
|
else:
|
||||||
client = icloud_api.IcloudClient(self.hass, token)
|
client = icloud_api.IcloudClient(self.hass, token)
|
||||||
try:
|
try:
|
||||||
@@ -670,7 +675,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors["base"] = "icloud_cannot_connect"
|
errors["base"] = "icloud_cannot_connect"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(
|
await self.async_set_unique_id(
|
||||||
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}"
|
f"{DOMAIN}:{PROVIDER_ICLOUD}:{token}:{name}"
|
||||||
)
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
@@ -678,6 +683,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
data={
|
data={
|
||||||
CONF_PROVIDER: PROVIDER_ICLOUD,
|
CONF_PROVIDER: PROVIDER_ICLOUD,
|
||||||
CONF_ICLOUD_TOKEN: token,
|
CONF_ICLOUD_TOKEN: token,
|
||||||
|
CONF_ICLOUD_BACKEND: backend,
|
||||||
CONF_ICLOUD_IMAGE_SIZE: size,
|
CONF_ICLOUD_IMAGE_SIZE: size,
|
||||||
CONF_ALBUM_NAME: name,
|
CONF_ALBUM_NAME: name,
|
||||||
},
|
},
|
||||||
@@ -866,7 +872,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
sel_id = json.dumps(selection, sort_keys=True)
|
sel_id = json.dumps(selection, sort_keys=True)
|
||||||
unique = (
|
unique = (
|
||||||
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
|
||||||
f"{self._syn_space}:{sel_id}"
|
f"{self._syn_space}:{sel_id}:{name}"
|
||||||
)
|
)
|
||||||
await self.async_set_unique_id(unique)
|
await self.async_set_unique_id(unique)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
@@ -957,7 +963,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(
|
await self.async_set_unique_id(
|
||||||
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
|
f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:"
|
||||||
f"{username}:{client.folder}"
|
f"{username}:{client.folder}:{name}"
|
||||||
)
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ CONF_ICLOUD_URL = "icloud_url"
|
|||||||
CONF_ICLOUD_TOKEN = "icloud_token"
|
CONF_ICLOUD_TOKEN = "icloud_token"
|
||||||
CONF_ICLOUD_IMAGE_SIZE = "icloud_image_size"
|
CONF_ICLOUD_IMAGE_SIZE = "icloud_image_size"
|
||||||
|
|
||||||
|
# Which iCloud backend serves the album. Older share links
|
||||||
|
# (``www.icloud.com/sharedalbum/#TOKEN``) use the legacy "shared streams" web
|
||||||
|
# API; iOS 26/macOS 26 and newer links
|
||||||
|
# (``photos.icloud.com/shared/album/TOKEN``) use CloudKit Web Services. Both are
|
||||||
|
# public and need no account. Entries created before this option existed default
|
||||||
|
# to the legacy backend.
|
||||||
|
CONF_ICLOUD_BACKEND = "icloud_backend"
|
||||||
|
ICLOUD_BACKEND_SHAREDSTREAMS = "sharedstreams"
|
||||||
|
ICLOUD_BACKEND_CLOUDKIT = "cloudkit"
|
||||||
|
DEFAULT_ICLOUD_BACKEND = ICLOUD_BACKEND_SHAREDSTREAMS
|
||||||
|
|
||||||
# ``full`` picks the largest derivative Apple generated (best for a slideshow,
|
# ``full`` picks the largest derivative Apple generated (best for a slideshow,
|
||||||
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
|
# usually ~2048px); ``preview`` picks the smallest (a thumbnail; fastest).
|
||||||
ICLOUD_IMAGE_FULL = "full"
|
ICLOUD_IMAGE_FULL = "full"
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ from .const import (
|
|||||||
CONF_ICLOUD_TOKEN,
|
CONF_ICLOUD_TOKEN,
|
||||||
CONF_ICLOUD_IMAGE_SIZE,
|
CONF_ICLOUD_IMAGE_SIZE,
|
||||||
DEFAULT_ICLOUD_IMAGE_SIZE,
|
DEFAULT_ICLOUD_IMAGE_SIZE,
|
||||||
|
CONF_ICLOUD_BACKEND,
|
||||||
|
DEFAULT_ICLOUD_BACKEND,
|
||||||
|
ICLOUD_BACKEND_CLOUDKIT,
|
||||||
CONF_SYNOLOGY_URL,
|
CONF_SYNOLOGY_URL,
|
||||||
CONF_SYNOLOGY_USERNAME,
|
CONF_SYNOLOGY_USERNAME,
|
||||||
CONF_SYNOLOGY_PASSWORD,
|
CONF_SYNOLOGY_PASSWORD,
|
||||||
@@ -1523,18 +1526,23 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
async def _update_icloud(self) -> dict[str, Any]:
|
async def _update_icloud(self) -> dict[str, Any]:
|
||||||
"""Fetch photos from a public iCloud Shared Album.
|
"""Fetch photos from a public iCloud Shared Album.
|
||||||
|
|
||||||
The webstream response carries capture date and caption inline, so
|
Two public backends are supported: the legacy "shared streams" API and
|
||||||
there is no enrichment pass. Signed image URLs are resolved up front
|
the newer CloudKit backend used by iOS 26/macOS 26 share links. Both
|
||||||
and expire after roughly a day, so they are refreshed on every album
|
carry capture date and caption inline, so there is no enrichment pass.
|
||||||
refresh (like Google Photos).
|
Signed image URLs are resolved up front and expire after a while, so
|
||||||
|
they are refreshed on every album refresh (like Google Photos).
|
||||||
"""
|
"""
|
||||||
from . import icloud as icloud_api
|
from . import icloud as icloud_api
|
||||||
|
|
||||||
token = self.entry.data.get(CONF_ICLOUD_TOKEN)
|
token = self.entry.data.get(CONF_ICLOUD_TOKEN)
|
||||||
size = self.entry.data.get(CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE)
|
size = self.entry.data.get(CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE)
|
||||||
|
backend = self.entry.data.get(CONF_ICLOUD_BACKEND, DEFAULT_ICLOUD_BACKEND)
|
||||||
if not token:
|
if not token:
|
||||||
raise UpdateFailed("iCloud provider is missing the album token")
|
raise UpdateFailed("iCloud provider is missing the album token")
|
||||||
|
|
||||||
|
if backend == ICLOUD_BACKEND_CLOUDKIT:
|
||||||
|
return await self._update_icloud_cloudkit(icloud_api, token, size)
|
||||||
|
|
||||||
client = icloud_api.IcloudClient(self.hass, token)
|
client = icloud_api.IcloudClient(self.hass, token)
|
||||||
try:
|
try:
|
||||||
photos = await client.async_get_photos()
|
photos = await client.async_get_photos()
|
||||||
@@ -1581,6 +1589,43 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
|||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _update_icloud_cloudkit(
|
||||||
|
self, icloud_api, token: str, size: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch photos from a CloudKit-backed iCloud shared album."""
|
||||||
|
client = icloud_api.IcloudCloudKitClient(self.hass, token)
|
||||||
|
try:
|
||||||
|
photos = await client.async_get_items(size)
|
||||||
|
except Exception as err:
|
||||||
|
raise UpdateFailed(f"Error querying iCloud album: {err}") from err
|
||||||
|
|
||||||
|
if not photos:
|
||||||
|
raise UpdateFailed("No images found in the iCloud album")
|
||||||
|
|
||||||
|
items: list[MediaItem] = [
|
||||||
|
MediaItem(
|
||||||
|
url=p["url"],
|
||||||
|
width=p.get("width"),
|
||||||
|
height=p.get("height"),
|
||||||
|
mime_type=None,
|
||||||
|
filename=None,
|
||||||
|
captured_at=p.get("captured_at"),
|
||||||
|
description=p.get("description"),
|
||||||
|
source_id=p.get("source_id"),
|
||||||
|
exif_scanned=True,
|
||||||
|
)
|
||||||
|
for p in photos
|
||||||
|
if p.get("url")
|
||||||
|
]
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
raise UpdateFailed("Could not resolve any iCloud image URLs")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": self.entry.title,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
async def _update_synology(self) -> dict[str, Any]:
|
async def _update_synology(self) -> dict[str, Any]:
|
||||||
"""Fetch photos from a Synology Photos library via its web API.
|
"""Fetch photos from a Synology Photos library via its web API.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""iCloud Shared Album client and pure parsing helpers.
|
"""iCloud Shared Album client and pure parsing helpers.
|
||||||
|
|
||||||
Talks to Apple's public "shared streams" web API for a shared photo album -
|
Reads a public iCloud shared photo album. No account or password is involved;
|
||||||
the same undocumented JSON endpoints the iCloud web album viewer uses. No
|
the album's share token (from the share link) is the only credential. Apple
|
||||||
account or password is involved; the album's share token (the part after
|
serves shared albums through two different public backends depending on when
|
||||||
``#`` in the share link) is the only credential.
|
the album was created, and this module speaks both:
|
||||||
|
|
||||||
API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com``):
|
Legacy "shared streams" backend (``www.icloud.com/sharedalbum/#TOKEN``):
|
||||||
- ``POST {base}/webstream`` ``{"streamCtag": null}`` -> ``{streamName, photos:
|
- ``POST {base}/webstream`` ``{"streamCtag": null}`` -> ``{streamName, photos:
|
||||||
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
|
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
|
||||||
dateCreated, caption, width, height}]}``. May first answer with a
|
dateCreated, caption, width, height}]}``. May first answer with a
|
||||||
@@ -16,13 +16,24 @@ API shape (POST, ``Content-Type: text/plain``, ``Origin: https://www.icloud.com`
|
|||||||
as ``https://{url_location}{url_path}``; it is a signed CDN link that
|
as ``https://{url_location}{url_path}``; it is a signed CDN link that
|
||||||
expires after roughly a day, so it is refreshed on every album refresh.
|
expires after roughly a day, so it is refreshed on every album refresh.
|
||||||
|
|
||||||
Metadata: capture date (``dateCreated``) and caption are inline. Apple strips
|
CloudKit backend (``photos.icloud.com/shared/album/TOKEN``, iOS 26/macOS 26+):
|
||||||
GPS from shared-album web data, so there is no location.
|
- ``POST ckdatabasews.icloud.com/.../public/records/resolve?shortGUID=TOKEN``
|
||||||
|
resolves the short link to a shared CloudKit zone and hands back an
|
||||||
|
anonymous ``publicAccessAuthToken`` plus the partition host to talk to.
|
||||||
|
- ``POST {partition}/.../shared/records/query`` with the anonymous token and
|
||||||
|
``sharing_url_key`` returns ``CPLMaster``/``CPLAsset`` records; each
|
||||||
|
``CPLMaster`` carries signed ``downloadURL`` derivatives directly.
|
||||||
|
|
||||||
|
Both backends are POST with ``Content-Type: text/plain`` and
|
||||||
|
``Origin: https://www.icloud.com``. Metadata (capture date, caption) is inline;
|
||||||
|
Apple strips GPS from shared-album web data, so there is no location.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
|
||||||
import async_timeout
|
import async_timeout
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
@@ -34,6 +45,16 @@ _MAX_ASSETS = 20_000
|
|||||||
_URL_BATCH = 25
|
_URL_BATCH = 25
|
||||||
|
|
||||||
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
# Characters allowed in a share token. Legacy shared-streams tokens are base62;
|
||||||
|
# the newer CloudKit short GUIDs use a URL-safe base64 alphabet, so they may
|
||||||
|
# also contain ``-`` and ``_`` (e.g. ``045YeI20-8u3X31bBPD5z9B_A``).
|
||||||
|
_TOKEN_CHARS = frozenset(_BASE62 + "-_")
|
||||||
|
|
||||||
|
# Which iCloud backend serves an album. These string values match the
|
||||||
|
# ``ICLOUD_BACKEND_*`` constants in ``const.py``; they are duplicated here to
|
||||||
|
# keep this module free of a hard dependency on ``const``.
|
||||||
|
BACKEND_SHAREDSTREAMS = "sharedstreams"
|
||||||
|
BACKEND_CLOUDKIT = "cloudkit"
|
||||||
|
|
||||||
# Headers Apple's web endpoints expect for the shared-streams API.
|
# Headers Apple's web endpoints expect for the shared-streams API.
|
||||||
_API_HEADERS = {
|
_API_HEADERS = {
|
||||||
@@ -42,6 +63,37 @@ _API_HEADERS = {
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- CloudKit backend constants -------------------------------------------
|
||||||
|
_CK_RESOLVE_HOST = "https://ckdatabasews.icloud.com"
|
||||||
|
_CK_CONTAINER = "com.apple.photos.cloud"
|
||||||
|
_CK_BUILD = "2626"
|
||||||
|
# Sorted, non-hidden, non-deleted assets. Returns both CPLMaster (image
|
||||||
|
# resources) and CPLAsset (metadata) records in one query.
|
||||||
|
_CK_RECORD_TYPE = "CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted"
|
||||||
|
_CK_PAGE = 200
|
||||||
|
|
||||||
|
_CK_HEADERS = {
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
"Origin": "https://www.icloud.com",
|
||||||
|
"Referer": "https://www.icloud.com/",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Image resource fields on a CPLMaster, in ascending size order, paired with
|
||||||
|
# their width/height sibling fields.
|
||||||
|
_CK_IMAGE_RES = (
|
||||||
|
("resJPEGThumbRes", "resJPEGThumbWidth", "resJPEGThumbHeight"),
|
||||||
|
("resJPEGMedRes", "resJPEGMedWidth", "resJPEGMedHeight"),
|
||||||
|
("resJPEGLargeRes", "resJPEGLargeWidth", "resJPEGLargeHeight"),
|
||||||
|
("resOriginalRes", "resOriginalWidth", "resOriginalHeight"),
|
||||||
|
)
|
||||||
|
# Original item types a browser can display directly. The JPEG derivatives are
|
||||||
|
# always browser-safe; the original is only used as a fallback when it is one
|
||||||
|
# of these.
|
||||||
|
_CK_BROWSER_SAFE_ORIGINAL = {"public.jpeg", "public.png"}
|
||||||
|
# itemType substrings that mark a master as a video (skipped in a slideshow).
|
||||||
|
_CK_VIDEO_HINTS = ("movie", "video", "mpeg-4", "quicktime")
|
||||||
|
|
||||||
|
|
||||||
def _base62_to_int(value: str) -> int:
|
def _base62_to_int(value: str) -> int:
|
||||||
result = 0
|
result = 0
|
||||||
@@ -53,23 +105,52 @@ def _base62_to_int(value: str) -> int:
|
|||||||
def parse_share_link(url: str) -> str | None:
|
def parse_share_link(url: str) -> str | None:
|
||||||
"""Extract the album share token from a pasted iCloud link.
|
"""Extract the album share token from a pasted iCloud link.
|
||||||
|
|
||||||
Accepts a full ``https://www.icloud.com/sharedalbum/#TOKEN`` link or a
|
Accepts a legacy ``https://www.icloud.com/sharedalbum/#TOKEN`` link, a new
|
||||||
bare token. Returns ``None`` if nothing token-like is found.
|
``https://photos.icloud.com/shared/album/TOKEN`` link, or a bare token.
|
||||||
|
Returns ``None`` if nothing token-like is found.
|
||||||
"""
|
"""
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
text = url.strip()
|
text = url.strip()
|
||||||
|
# Legacy links carry the token in the fragment; new links carry it as the
|
||||||
|
# last path segment.
|
||||||
if "#" in text:
|
if "#" in text:
|
||||||
text = text.rsplit("#", 1)[1]
|
text = text.rsplit("#", 1)[1]
|
||||||
elif "/" in text:
|
elif "/" in text:
|
||||||
text = text.rstrip("/").rsplit("/", 1)[1]
|
text = text.rstrip("/").rsplit("/", 1)[1]
|
||||||
# Tokens are base62 and start with an uppercase letter (A, B, ...).
|
# Drop any leftover query string.
|
||||||
|
text = text.split("?", 1)[0]
|
||||||
token = text.strip()
|
token = text.strip()
|
||||||
if token and all(ch in _BASE62 for ch in token):
|
# Legacy tokens are base62 and start with an uppercase letter; the newer
|
||||||
|
# CloudKit short GUIDs may start with a digit and can contain ``-``/``_``,
|
||||||
|
# so accept the wider URL-safe alphabet without a leading-char check.
|
||||||
|
if token and all(ch in _TOKEN_CHARS for ch in token):
|
||||||
return token
|
return token
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_backend(url: str) -> str:
|
||||||
|
"""Return which iCloud backend a pasted share link belongs to.
|
||||||
|
|
||||||
|
New ``photos.icloud.com/shared/album/...`` links use the CloudKit backend;
|
||||||
|
everything else (including bare tokens) defaults to the legacy shared
|
||||||
|
streams backend, preserving behaviour for links created before CloudKit.
|
||||||
|
"""
|
||||||
|
text = (url or "").lower()
|
||||||
|
if "/shared/album/" in text or "photos.icloud.com" in text:
|
||||||
|
return BACKEND_CLOUDKIT
|
||||||
|
return BACKEND_SHAREDSTREAMS
|
||||||
|
|
||||||
|
|
||||||
|
def parse_share(url: str) -> tuple[str, str] | None:
|
||||||
|
"""Parse a share link into ``(token, backend)`` or ``None`` if invalid."""
|
||||||
|
token = parse_share_link(url)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return token, detect_backend(url)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def partition_host(token: str) -> str:
|
def partition_host(token: str) -> str:
|
||||||
"""Derive the shared-streams partition host for a token.
|
"""Derive the shared-streams partition host for a token.
|
||||||
|
|
||||||
@@ -250,3 +331,248 @@ class IcloudClient:
|
|||||||
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
|
||||||
payload = _json.loads(raw)
|
payload = _json.loads(raw)
|
||||||
return payload.get("streamName") if isinstance(payload, dict) else None
|
return payload.get("streamName") if isinstance(payload, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
# --- CloudKit backend helpers ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ck_value(fields: dict[str, Any], name: str) -> Any:
|
||||||
|
"""Return the inner ``value`` of a CloudKit field, or ``None``."""
|
||||||
|
field = fields.get(name) if isinstance(fields, dict) else None
|
||||||
|
return field.get("value") if isinstance(field, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ck_decode_filename(fields: dict[str, Any]) -> str | None:
|
||||||
|
"""Decode the (base64) ``filenameEnc`` field of a CPLMaster, if present."""
|
||||||
|
raw = _ck_value(fields, "filenameEnc")
|
||||||
|
if isinstance(raw, str) and raw:
|
||||||
|
try:
|
||||||
|
return base64.b64decode(raw).decode("utf-8", "replace")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ck_is_video(master_fields: dict[str, Any]) -> bool:
|
||||||
|
"""Return ``True`` when a CPLMaster describes a video (not a still image).
|
||||||
|
|
||||||
|
Only the ``itemType`` is used: Live Photos keep an image ``itemType`` (and
|
||||||
|
carry a non-zero asset duration), so they are treated as stills and shown.
|
||||||
|
"""
|
||||||
|
item_type = _ck_value(master_fields, "itemType")
|
||||||
|
if isinstance(item_type, str):
|
||||||
|
lowered = item_type.lower()
|
||||||
|
return any(hint in lowered for hint in _CK_VIDEO_HINTS)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def pick_ck_resource(
|
||||||
|
master_fields: dict[str, Any], size: str
|
||||||
|
) -> tuple[str, Any, Any] | None:
|
||||||
|
"""Pick a downloadable image derivative for the requested display ``size``.
|
||||||
|
|
||||||
|
Returns ``(download_url, width, height)``. ``full`` selects the largest
|
||||||
|
available derivative (best for a slideshow); ``preview`` selects the
|
||||||
|
smallest. JPEG derivatives are always browser-safe; the original is only
|
||||||
|
considered when it is itself a browser-displayable format.
|
||||||
|
"""
|
||||||
|
if not isinstance(master_fields, dict):
|
||||||
|
return None
|
||||||
|
item_type = _ck_value(master_fields, "itemType")
|
||||||
|
item_type = item_type.lower() if isinstance(item_type, str) else ""
|
||||||
|
original_safe = item_type in _CK_BROWSER_SAFE_ORIGINAL
|
||||||
|
|
||||||
|
candidates: list[tuple[int, str, Any, Any]] = []
|
||||||
|
for res_key, w_key, h_key in _CK_IMAGE_RES:
|
||||||
|
res = master_fields.get(res_key)
|
||||||
|
if not isinstance(res, dict):
|
||||||
|
continue
|
||||||
|
val = res.get("value")
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
continue
|
||||||
|
download_url = val.get("downloadURL")
|
||||||
|
if not download_url:
|
||||||
|
continue
|
||||||
|
if res_key == "resOriginalRes" and not original_safe:
|
||||||
|
continue
|
||||||
|
width = _ck_value(master_fields, w_key)
|
||||||
|
height = _ck_value(master_fields, h_key)
|
||||||
|
try:
|
||||||
|
rank = int(width)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rank = 0
|
||||||
|
candidates.append((rank, download_url, width, height))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(key=lambda c: c[0])
|
||||||
|
_rank, download_url, width, height = candidates[0 if size == "preview" else -1]
|
||||||
|
return download_url, width, height
|
||||||
|
|
||||||
|
|
||||||
|
def build_ck_image_url(download_url: str | None, filename: str | None = None) -> str | None:
|
||||||
|
"""Fill the ``${f}`` filename placeholder in a CloudKit download URL."""
|
||||||
|
if not download_url:
|
||||||
|
return None
|
||||||
|
return download_url.replace("${f}", quote(filename or "image", safe=""))
|
||||||
|
|
||||||
|
|
||||||
|
def _ck_share_title(resolve_result: dict[str, Any]) -> str | None:
|
||||||
|
"""Return the album title from a resolve result's share record, if any."""
|
||||||
|
share = resolve_result.get("share") if isinstance(resolve_result, dict) else None
|
||||||
|
fields = share.get("fields") if isinstance(share, dict) else None
|
||||||
|
title = _ck_value(fields, "cloudkit.title") if isinstance(fields, dict) else None
|
||||||
|
if isinstance(title, str) and title.strip():
|
||||||
|
return title.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value: Any) -> int | None:
|
||||||
|
return int(value) if isinstance(value, (int, float)) else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ck_records(records: list[Any], size: str) -> list[dict[str, Any]]:
|
||||||
|
"""Join CPLMaster + CPLAsset records into normalized photo dicts.
|
||||||
|
|
||||||
|
Each returned dict has ``source_id``, ``url``, ``width``, ``height``,
|
||||||
|
``captured_at`` (epoch ms) and ``description``.
|
||||||
|
"""
|
||||||
|
masters: dict[str, dict[str, Any]] = {}
|
||||||
|
assets: list[dict[str, Any]] = []
|
||||||
|
for rec in records:
|
||||||
|
if not isinstance(rec, dict):
|
||||||
|
continue
|
||||||
|
if rec.get("recordType") == "CPLMaster":
|
||||||
|
masters[rec.get("recordName")] = rec.get("fields") or {}
|
||||||
|
elif rec.get("recordType") == "CPLAsset":
|
||||||
|
assets.append(rec)
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for asset in assets:
|
||||||
|
af = asset.get("fields") or {}
|
||||||
|
if _ck_value(af, "isHidden") or _ck_value(af, "trashReason"):
|
||||||
|
continue
|
||||||
|
ref = af.get("masterRef")
|
||||||
|
ref_val = ref.get("value") if isinstance(ref, dict) else None
|
||||||
|
master_name = ref_val.get("recordName") if isinstance(ref_val, dict) else None
|
||||||
|
master_fields = masters.get(master_name)
|
||||||
|
if not master_fields or _ck_is_video(master_fields):
|
||||||
|
continue
|
||||||
|
picked = pick_ck_resource(master_fields, size)
|
||||||
|
if not picked:
|
||||||
|
continue
|
||||||
|
download_url, width, height = picked
|
||||||
|
url = build_ck_image_url(download_url, _ck_decode_filename(master_fields))
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
source_id = master_name or asset.get("recordName")
|
||||||
|
if not source_id or source_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(source_id)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"source_id": source_id,
|
||||||
|
"url": url,
|
||||||
|
"width": _to_int(width),
|
||||||
|
"height": _to_int(height),
|
||||||
|
"captured_at": _to_int(_ck_value(af, "assetDate")),
|
||||||
|
"description": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class IcloudCloudKitClient:
|
||||||
|
"""Async client for the CloudKit-backed iCloud shared album backend.
|
||||||
|
|
||||||
|
Everything is anonymous: a ``resolve`` call turns the short share token
|
||||||
|
into a shared CloudKit zone plus a short-lived anonymous access token, and
|
||||||
|
a ``records/query`` call returns the album's photos with signed image URLs
|
||||||
|
already embedded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass, token: str) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self.token = token
|
||||||
|
# Cached (zone, base_url, access_token, title) from the resolve call.
|
||||||
|
self._resolved: tuple[dict[str, Any], str, str, str | None] | None = None
|
||||||
|
|
||||||
|
async def _post(self, url: str, body: dict[str, Any]) -> tuple[int, bytes]:
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
async with async_timeout.timeout(_TIMEOUT):
|
||||||
|
async with session.post(url, json=body, headers=_CK_HEADERS) as resp:
|
||||||
|
return resp.status, await resp.read()
|
||||||
|
|
||||||
|
async def _resolve(self) -> tuple[dict[str, Any], str, str, str | None]:
|
||||||
|
if self._resolved is not None:
|
||||||
|
return self._resolved
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"{_CK_RESOLVE_HOST}/database/1/{_CK_CONTAINER}/production"
|
||||||
|
f"/public/records/resolve?ckjsBuildVersion={_CK_BUILD}"
|
||||||
|
f"&shortGUID={self.token}"
|
||||||
|
)
|
||||||
|
status, raw = await self._post(url, {"shortGUIDs": [{"value": self.token}]})
|
||||||
|
if status != 200:
|
||||||
|
raise RuntimeError(f"iCloud resolve failed: HTTP {status}")
|
||||||
|
payload = _json.loads(raw)
|
||||||
|
results = payload.get("results") if isinstance(payload, dict) else None
|
||||||
|
if not results:
|
||||||
|
raise RuntimeError("iCloud share link did not resolve to an album")
|
||||||
|
result = results[0]
|
||||||
|
zone = result.get("zoneID")
|
||||||
|
access = result.get("anonymousPublicAccess") or {}
|
||||||
|
access_token = access.get("token")
|
||||||
|
partition = access.get("databasePartition")
|
||||||
|
if not zone or not access_token or not partition:
|
||||||
|
raise RuntimeError("iCloud shared album is not publicly accessible")
|
||||||
|
base = f"{partition}/database/1/{_CK_CONTAINER}/production"
|
||||||
|
self._resolved = (zone, base, access_token, _ck_share_title(result))
|
||||||
|
return self._resolved
|
||||||
|
|
||||||
|
def _query_url(self, base: str, access_token: str) -> str:
|
||||||
|
query = urlencode(
|
||||||
|
{
|
||||||
|
"remapEnums": "true",
|
||||||
|
"getCurrentSyncToken": "true",
|
||||||
|
"sharing_url_key": self.token,
|
||||||
|
"publicAccessAuthToken": access_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return f"{base}/shared/records/query?{query}"
|
||||||
|
|
||||||
|
async def async_validate(self) -> str | None:
|
||||||
|
"""Return the album title if the share link resolves, else raise."""
|
||||||
|
_zone, _base, _token, title = await self._resolve()
|
||||||
|
return title
|
||||||
|
|
||||||
|
async def async_get_items(self, size: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return normalized photo dicts for the album (see ``parse_ck_records``)."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
zone, base, access_token, _title = await self._resolve()
|
||||||
|
url = self._query_url(base, access_token)
|
||||||
|
records: list[Any] = []
|
||||||
|
continuation: str | None = None
|
||||||
|
while len(records) < _MAX_ASSETS:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"zoneID": zone,
|
||||||
|
"query": {"recordType": _CK_RECORD_TYPE},
|
||||||
|
"resultsLimit": _CK_PAGE,
|
||||||
|
}
|
||||||
|
if continuation:
|
||||||
|
body["continuationMarker"] = continuation
|
||||||
|
status, raw = await self._post(url, body)
|
||||||
|
if status != 200:
|
||||||
|
raise RuntimeError(f"iCloud records query failed: HTTP {status}")
|
||||||
|
payload = _json.loads(raw)
|
||||||
|
batch = payload.get("records") if isinstance(payload, dict) else None
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
records.extend(batch)
|
||||||
|
continuation = payload.get("continuationMarker") if isinstance(payload, dict) else None
|
||||||
|
if not continuation:
|
||||||
|
break
|
||||||
|
return parse_ck_records(records, size)
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||||
"requirements": ["Pillow"],
|
"requirements": ["Pillow"],
|
||||||
"version": "1.6.2"
|
"version": "1.7.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
},
|
},
|
||||||
"icloud": {
|
"icloud": {
|
||||||
"title": "iCloud Shared Album",
|
"title": "iCloud Shared Album",
|
||||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). No Apple ID or password is needed - the link itself is the credential. Capture date and captions come through; Apple does not include location in shared albums.",
|
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). Both the older icloud.com/sharedalbum links and the newer photos.icloud.com/shared/album links work. No Apple ID or password is needed - the link itself is the credential. Capture date comes through; Apple does not include location in shared albums.",
|
||||||
"data": {
|
"data": {
|
||||||
"album_name": "Album name",
|
"album_name": "Album name",
|
||||||
"icloud_url": "Shared album link",
|
"icloud_url": "Shared album link",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
},
|
},
|
||||||
"icloud": {
|
"icloud": {
|
||||||
"title": "iCloud Shared Album",
|
"title": "iCloud Shared Album",
|
||||||
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). No Apple ID or password is needed - the link itself is the credential. Capture date and captions come through; Apple does not include location in shared albums.",
|
"description": "Paste the public link to an iCloud Shared Album (in Photos, open the album, tap the share icon, and copy the Public Website link). Both the older icloud.com/sharedalbum links and the newer photos.icloud.com/shared/album links work. No Apple ID or password is needed - the link itself is the credential. Capture date comes through; Apple does not include location in shared albums.",
|
||||||
"data": {
|
"data": {
|
||||||
"album_name": "Album name",
|
"album_name": "Album name",
|
||||||
"icloud_url": "Shared album link",
|
"icloud_url": "Shared album link",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
* tap_action: none # none | more-info
|
* tap_action: none # none | more-info
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VERSION = "1.6.2";
|
const VERSION = "1.7.1";
|
||||||
|
|
||||||
const ANIMATED_TRANSITIONS = [
|
const ANIMATED_TRANSITIONS = [
|
||||||
"fade",
|
"fade",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"domain": "cafe",
|
||||||
|
"name": "C.A.F.E.",
|
||||||
|
"version": "0.7.0",
|
||||||
|
"codeowners": [],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["frontend", "http"],
|
||||||
|
"documentation": "https://github.com/FezVrasta/cafe-hass",
|
||||||
|
"documentation_url": "https://github.com/FezVrasta/cafe-hass",
|
||||||
|
"integration_type": "service",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"issue_tracker": "https://github.com/FezVrasta/cafe-hass/issues",
|
||||||
|
"requirements": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"title": "C.A.F.E.",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Set up C.A.F.E.",
|
||||||
|
"description": "C.A.F.E. is a visual automation editor for Home Assistant. It allows you to create and manage your automations using a visual flow editor.\n\nClick Submit to add C.A.F.E. to your Home Assistant."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "C.A.F.E. is already configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"title": "C.A.F.E.",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Set up C.A.F.E.",
|
||||||
|
"description": "C.A.F.E. is a visual automation editor for Home Assistant. It allows you to create and manage your automations using a visual flow editor.\n\nClick Submit to add C.A.F.E. to your Home Assistant."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "C.A.F.E. is already configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +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 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
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"domain": "frigate",
|
||||||
|
"name": "Frigate",
|
||||||
|
"codeowners": [
|
||||||
|
"@blakeblackshear",
|
||||||
|
"@dermotduffy",
|
||||||
|
"@NickM-27"
|
||||||
|
],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": [
|
||||||
|
"http",
|
||||||
|
"media_source",
|
||||||
|
"mqtt"
|
||||||
|
],
|
||||||
|
"documentation": "https://github.com/blakeblackshear/frigate",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues",
|
||||||
|
"requirements": ["hass-web-proxy-lib==0.0.8","titlecase==2.4.1"],
|
||||||
|
"version": "5.15.4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
export_recording:
|
||||||
|
name: Export recording
|
||||||
|
description: Export a custom recording or timelapse.
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
playback_factor:
|
||||||
|
name: Playback Factor
|
||||||
|
description: Playback factor for recordings
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: realtime
|
||||||
|
default: realtime
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- "realtime"
|
||||||
|
- "timelapse_25x"
|
||||||
|
start_time:
|
||||||
|
name: Export Start Time
|
||||||
|
description: Start time of exported recording
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
end_time:
|
||||||
|
name: Export End Time
|
||||||
|
description: End time of exported recording
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
name:
|
||||||
|
name: Name
|
||||||
|
description: >
|
||||||
|
Optional name for the exported recording. If not provided, the API will
|
||||||
|
generate one.
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
favorite_event:
|
||||||
|
name: Favorite or unfavorite Event
|
||||||
|
description: >
|
||||||
|
Favorites or unfavorites an event. Favorited events are retained
|
||||||
|
indefinitely.
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
event_id:
|
||||||
|
name: Event ID
|
||||||
|
description: ID of the event to favorite or unfavorite.
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: "1656510950.19548-ihtjj7"
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
favorite:
|
||||||
|
name: Favorite
|
||||||
|
description: >
|
||||||
|
If the event should be favorited or unfavorited. Enable to favorite,
|
||||||
|
disable to unfavorite.
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: true
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
ptz:
|
||||||
|
name: Control camera via PTZ
|
||||||
|
description: >
|
||||||
|
Pan / Tilt, Zoom, or move a camera to a preset
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
action:
|
||||||
|
name: PTZ Service
|
||||||
|
description: Type of PTZ action
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: move
|
||||||
|
default: move
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- "move"
|
||||||
|
- "preset"
|
||||||
|
- "stop"
|
||||||
|
- "zoom"
|
||||||
|
argument:
|
||||||
|
name: PTZ Action
|
||||||
|
description: >
|
||||||
|
left, right, up, down for move; in, out for zoom; name of preset
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: down
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
create_event:
|
||||||
|
name: Create an event
|
||||||
|
description: >
|
||||||
|
Create a manual event with a given label for a camera.
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
label:
|
||||||
|
name: Label
|
||||||
|
description: Label for the event
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: "Doorbell press"
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
sub_label:
|
||||||
|
name: Sub Label
|
||||||
|
description: Sub label for the event
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: "Front door"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
duration:
|
||||||
|
name: Duration
|
||||||
|
description: >
|
||||||
|
Predetermined length of event.
|
||||||
|
Default is 30 seconds. Use 0 for indefinite.
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: 30
|
||||||
|
default: 30
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 0
|
||||||
|
max: 300
|
||||||
|
step: 1
|
||||||
|
include_recording:
|
||||||
|
name: Include Recording
|
||||||
|
description: >
|
||||||
|
Whether the event should save recordings along
|
||||||
|
with the snapshot that is taken.
|
||||||
|
required: false
|
||||||
|
advanced: false
|
||||||
|
example: true
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
end_event:
|
||||||
|
name: End an event
|
||||||
|
description: >
|
||||||
|
End a manual event with a given id for a camera.
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: frigate
|
||||||
|
domain: camera
|
||||||
|
device_class: camera
|
||||||
|
fields:
|
||||||
|
event_id:
|
||||||
|
name: Event ID
|
||||||
|
description: ID of the event to end.
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
example: "1656510950.19548-ihtjj7"
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
review_summarize:
|
||||||
|
name: Review Summarize
|
||||||
|
description: >
|
||||||
|
Get a summary of review items for a specified time period.
|
||||||
|
Only available in Frigate 0.17+.
|
||||||
|
fields:
|
||||||
|
start_time:
|
||||||
|
name: Start Time
|
||||||
|
description: Start time for the review period
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
end_time:
|
||||||
|
name: End Time
|
||||||
|
description: End time for the review period
|
||||||
|
required: true
|
||||||
|
advanced: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "L'URL que utilitzeu per accedir a Frigate (p. ex. 'http://frigate:5000/')\\n\\nSi feu servir HassOS amb el complement, l'URL hauria de ser 'http://ccab4aaf-frigate:5000/' \\n\\nHome Assistant necessita accedir al port 5000 (api) i 8554/8555 (rtsp, webrtc) per a totes les funcions.\\n\\nLa integració configurarà sensors, càmeres i la funcionalitat del navegador multimèdia.\\n\\nSensors:\\n- Estadístiques per supervisar el rendiment de Frigate\\n- Recompte d'objectes per a totes les zones i càmeres\\n\\nCàmeres:\\n- Càmeres per a la imatge de l'últim objecte detectat per a cada càmera\\n- Entitats de càmera amb suport de transmissió\\n\\nNavegador multimèdia:\\n - Interfície d'usuari enriquida amb miniatures per explorar clips d'esdeveniments\\n- Interfície d'usuari enriquida per navegar per enregistraments les 24 hores al dia, els set dies a la setmana, per mes, dia, càmera, hora\\n\\nAPI:\\n- API de notificació amb punts de connexió públics per a imatges a les notificacions.",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "No s'ha pogut connectar",
|
||||||
|
"invalid_url": "URL no vàlid"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "El dispositiu ja està configurat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"rtsp_url_template": "Plantilla de l'URL del RTSP (vegeu la documentació)",
|
||||||
|
"media_browser_enable": "Habiliteu el navegador multimèdia",
|
||||||
|
"notification_proxy_enable": "Habiliteu el servidor intermediari no autenticat d'esdeveniments de notificacions",
|
||||||
|
"notification_proxy_expire_after_seconds": "No permetre l'accés a notificacions no autenticades després dels segons especificats (0=mai)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "El mode avançat està desactivat i només hi ha opcions avançades"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL, die Sie für den Zugriff auf Frigate verwenden (z. B. \"http://frigate:5000/\")\n\nWenn Sie HassOS mit dem Addon verwenden, sollte die URL „http://ccab4aaf-frigate:5000/“ lauten\n\nHome Assistant benötigt für alle Funktionen Zugriff auf Port 5000 (api) und 8554/8555 (rtsp, webrtc).\n\nDie Integration richtet Sensoren, Kameras und Medienbrowser-Funktionen ein.\n\nSensoren:\n- Statistiken zur Überwachung der Frigate-Leistung\n- Objektzählungen für alle Zonen und Kameras\n\nKameras:\n- Kameras für Bild des zuletzt erkannten Objekts für jede Kamera\n- Kameraeinheiten mit Stream-Unterstützung\n\nMedienbrowser:\n- Umfangreiche Benutzeroberfläche mit Vorschaubildern zum Durchsuchen von Event-Clips\n- Umfangreiche Benutzeroberfläche zum Durchsuchen von 24/7-Aufzeichnungen nach Monat, Tag, Kamera und Uhrzeit\n\nAPI:\n- Benachrichtigungs-API mit öffentlich zugänglichen Endpunkten für Bilder in Benachrichtigungen",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Verbindung fehlgeschlagen",
|
||||||
|
"invalid_url": "Ungültige URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Gerät ist bereits konfiguriert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"rtsp_url_template": "RTSP-URL-Vorlage (siehe Dokumentation)",
|
||||||
|
"media_browser_enable": "Aktivieren Sie den Medienbrowser",
|
||||||
|
"notification_proxy_enable": "Aktivieren Sie den Proxy für nicht authentifizierte Benachrichtigungsereignisse",
|
||||||
|
"notification_proxy_expire_after_seconds": "Zugriff auf nicht authentifizierte Benachrichtigungen nach Sekunden verbieten (0=nie)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Der erweiterte Modus ist deaktiviert und es stehen nur erweiterte Optionen zur Verfügung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL you use to access Frigate (ie. `http://frigate:5000/`)\n\nIf you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000/`\n\nHome Assistant needs access to port 5000 (api) and 8554/8555 (rtsp, webrtc) for all features.\n\nThe integration will setup sensors, cameras, and media browser functionality.\n\nSensors:\n- Stats to monitor frigate performance\n- Object counts for all zones and cameras\n\nCameras:\n- Cameras for image of the last detected object for each camera\n- Camera entities with stream support\n\nMedia Browser:\n- Rich UI with thumbnails for browsing event clips\n- Rich UI for browsing 24/7 recordings by month, day, camera, time\n\nAPI:\n- Notification API with public facing endpoints for images in notifications",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "Validate SSL",
|
||||||
|
"username": "Username (optional)",
|
||||||
|
"password": "Password (optional)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_url": "Invalid URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "Use Frigate-native WebRTC support",
|
||||||
|
"rtsp_url_template": "RTSP URL template (see documentation)",
|
||||||
|
"media_browser_enable": "Enable the media browser",
|
||||||
|
"notification_proxy_enable": "Enable the unauthenticated notification event proxy",
|
||||||
|
"notification_proxy_expire_after_seconds": "Disallow unauthenticated notification access after seconds (0=never)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Advanced mode is disabled and there are only advanced options"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL que vous utilisez pour accéder à Frigate (par exemple, `http://frigate:5000/`)\n\nSi vous utilisez HassOS avec l'addon, l'URL devrait être `http://ccab4aaf-frigate:5000/`\n\nHome Assistant a besoin d'accès au port 5000 (api) et 8554/8555 (rtsp, webrtc) pour toutes les fonctionnalités.\n\nL'intégration configurera des capteurs, des caméras et la fonctionnalité de navigateur multimédia.\n\nCapteurs :\n- Statistiques pour surveiller la performance de Frigate\n- Comptes d'objets pour toutes les zones et caméras\n\nCaméras :\n- Caméras pour l'image du dernier objet détecté pour chaque caméra\n- Entités de caméra avec support de flux\n\nNavigateur multimédia :\n- Interface riche avec miniatures pour parcourir les clips d'événements\n- Interface riche pour parcourir les enregistrements 24/7 par mois, jour, caméra, heure\n\nAPI :\n- API de notification avec des points de terminaison publics pour les images dans les notifications",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "Valider SSL",
|
||||||
|
"username": "Nom d'utilisateur (facultatif)",
|
||||||
|
"password": "Mot de passe (facultatif)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Échec de la connexion",
|
||||||
|
"invalid_url": "URL invalide"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "L'appareil est déjà configuré"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "Utiliser le support WebRTC natif de Frigate",
|
||||||
|
"rtsp_url_template": "Modèle d'URL RTSP (voir la documentation)",
|
||||||
|
"media_browser_enable": "Activer le navigateur multimédia",
|
||||||
|
"notification_proxy_enable": "Activer le proxy d'événement de notification non authentifié",
|
||||||
|
"notification_proxy_expire_after_seconds": "Interdire l'accès à la notification non authentifiée après secondes (0=jamais)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Le mode avancé est désactivé et il n'y a que des options avancées"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 8554/8555 (rtsp, webrtc) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Falhou ao conectar",
|
||||||
|
"invalid_url": "URL inválida"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "O dispositivo já está configurado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"rtsp_url_template": "Modelo de URL RTSP (consulte a documentação)",
|
||||||
|
"notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado",
|
||||||
|
"notification_proxy_expire_after_seconds": "Proibir acesso de notificação não autenticado após segundos (0=nunca)",
|
||||||
|
"media_browser_enable": "Ative o navegador de mídia"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL que usa para aceder ao Frigate (ou seja, `http://frigate:5000/`)\n\nSe estiver usar HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 8554/8555 (rtsp, webrtc) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Falhou a ligar",
|
||||||
|
"invalid_url": "Link inválido"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "O dispositivo já está configurado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"notification_proxy_enable": "Activar o proxy de evento de notificação não autenticado"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL, который вы используете для доступа к Frigate (например, http://frigate:5000/)\n\nЕсли вы используете HassOS с дополнением, URL должен быть http://ccab4aaf-frigate:5000/\n\nHome Assistant требуется доступ к порту 5000 (API) и 8554/8555 (RTSP, WEBRTC) для всех функций.\n\n Интеграция настроит сенсоры, камеры и функциональность медиа-браузера.\n\nСенсоры:\n- Статистика для отслеживания производительности Frigate\n- Количество объектов для всех зон и камер\n\nКамеры:\n- Камеры для снимка последнего обнаруженного объекта с каждой камеры\n- Камеры с поддержкой потока\n\nМедиа-браузер:\n- Пользовательский интерфейс с миниатюрами для просмотра записей 24/7 по времени и камерам\n\nAPI:\n- API для отправки событий во внешние системы",
|
||||||
|
"data": {
|
||||||
|
"url": "URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Не удалось подключиться",
|
||||||
|
"invalid_url": "Неверный URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Устройство уже настроено"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"rtsp_url_template": "Шаблон URL для RTSP (см. документацию)",
|
||||||
|
"media_browser_enable": "Включить медиа-браузер",
|
||||||
|
"notification_proxy_enable": "Включить незащищённый прокси-сервер уведомлений",
|
||||||
|
"notification_proxy_expire_after_seconds": "Запретить неаутентифицированный доступ к уведомлениям после N секунд (0=никогда)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Режим расширенных настроек отключен; доступны только основные параметры"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "URL du använder för att komma åt Frigate (t.ex. `http://frigate:5000/`)\n\nOm du använder HassOS med tillägget ska URL:en vara `http://ccab4aaf-frigate:5000/`\n\nHome Assistant behöver åtkomst till port 5000 (api) och 8554/8555 (rtsp, webrtc) för alla funktioner.\n\nIntegrationen kommer att konfigurera sensorer, kameror och medialäsarfunktioner.\n\nSensorer:\n- Statistik för att övervaka frigates prestanda\n- Objekträkning för alla zoner och kameror\n\nKameror:\n- Kameror för bild av det senast detekterade objektet för varje kamera\n- Kameraenheter med strömningsstöd\n\nMedialäsare:\n- Rikt användargränssnitt med miniatyrbilder för att bläddra bland händelseklipp\n- Rikt användargränssnitt för att bläddra bland inspelningar dygnet runt efter månad, dag, kamera, tid\n\nAPI:\n- Aviserings-API med offentliga slutpunkter för bilder i aviseringar",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "Validera SSL",
|
||||||
|
"username": "Användarnamn (valfritt)",
|
||||||
|
"password": "Lösenord (valfritt)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Misslyckades med att ansluta",
|
||||||
|
"invalid_url": "Ogiltig URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Enheten är redan konfigurerad"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "Använd Frigate-nativt WebRTC-stöd",
|
||||||
|
"rtsp_url_template": "RTSP URL-mall (se dokumentation)",
|
||||||
|
"media_browser_enable": "Aktivera medieläsaren",
|
||||||
|
"notification_proxy_enable": "Aktivera proxy för oautentiserade aviseringshändelser",
|
||||||
|
"notification_proxy_expire_after_seconds": "Tillåt inte åtkomst till oautentiserade aviseringar efter sekunder (0=aldrig)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Avancerat läge är inaktiverat och det finns bara avancerade alternativ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Frigate'e erişmek için kullandığınız URL (örneğin `http://frigate:5000/`)\n\nHassOS ile eklentiyi kullanıyorsanız, URL `http://ccab4aaf-frigate:5000/` olmalıdır\n\nHome Assistant'ın tüm özellikleri kullanabilmesi için 5000 (api) ve 8554/8555 (rtsp, webrtc) portlarına erişimi olmalıdır.\n\nEntegrasyon sensörler, kameralar ve medya tarayıcısı işlevselliğini kuracaktır.\n\nSensörler:\n- Frigate performansını izlemek için istatistikler\n- Tüm bölgeler ve kameralar için nesne sayıları\n\nKameralar:\n- Her kamera için son Algılanan nesnenin görüntüsünü gösteren kameralar\n- Yayın destekli kamera varlıkları\n\nMedya Tarayıcısı:\n- Olay kliplerini göz atmak için küçük resimli zengin arayüz\n- Ay, gün, kamera ve zamana göre 7/24 kayıtları göz atmak için zengin arayüz\n\nAPI:\n- Bildirimlerde görüntüler için herkese açık uçlara sahip bildirim API'si",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "SSL Doğrula",
|
||||||
|
"username": "Kullanıcı Adı (isteğe bağlı)",
|
||||||
|
"password": "Şifre (isteğe bağlı)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Bağlantı kurulamadi",
|
||||||
|
"invalid_url": "Geçersiz URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Cihaz zaten yapılandırılmış"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "Frigate'in yerel WebRTC desteğini kullan",
|
||||||
|
"rtsp_url_template": "RTSP URL şablonu (dokümantasyona bakınız)",
|
||||||
|
"media_browser_enable": "Medya tarayıcısını etkinleştir",
|
||||||
|
"notification_proxy_enable": "Kimlik dogrulamasi gerektirmeyen bildirim olay proxy'sini etkinlestir",
|
||||||
|
"notification_proxy_expire_after_seconds": "Kimlik doğrulaması gerektirmeyen bildirim erişimini belirtilen saniye sonra devre dışı bırak (0=asla)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "Gelişmiş mod devre dışı ve sadece gelişmiş seçenekler mevcut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "您用于访问 Frigate 的 URL(例如:`http://frigate:5000/`)\n\n如果您正在使用带有插件的 HassOS,URL 应该是 `http://ccab4aaf-frigate:5000/`\n\nHome Assistant 需要访问端口 5000(API)和 8554/8555(RTSP,WebRTC)才能使用所有功能。\n\n该集成将设置传感器、摄像头和媒体浏览器功能。\n\n传感器:\n- 监控 Frigate 性能的统计数据\n- 所有区域和摄像头的对象计数\n\n摄像头:\n- 每个摄像头最后检测到的对象图像的摄像头\n- 支持流的摄像头实体\n\n媒体浏览器:\n- 带有缩略图的丰富用户界面,用于浏览事件剪辑\n- 丰富的用户界面,用于按月、日、摄像头、时间浏览全天候录像\n\nAPI:\n- 通知 API,具有用于通知中图像的公共接口",
|
||||||
|
"data": {
|
||||||
|
"url": "URL",
|
||||||
|
"validate_ssl": "验证 SSL",
|
||||||
|
"username": "用户名(可选)",
|
||||||
|
"password": "密码(可选)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "连接失败",
|
||||||
|
"invalid_url": "无效的 URL"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "设备已配置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"enable_webrtc": "使用 Frigate 原生 WebRTC 支持",
|
||||||
|
"rtsp_url_template": "RTSP URL 模板(参见文档)",
|
||||||
|
"media_browser_enable": "启用媒体浏览器",
|
||||||
|
"notification_proxy_enable": "启用未经身份验证的通知事件代理",
|
||||||
|
"notification_proxy_expire_after_seconds": "在指定秒数后禁止未经身份验证的通知访问(0=永不)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"only_advanced_options": "高级模式已禁用,且只有高级选项可用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ from .const import (
|
|||||||
TOOLS_ENTRY_LEGACY_TITLE,
|
TOOLS_ENTRY_LEGACY_TITLE,
|
||||||
TOOLS_ENTRY_TITLE,
|
TOOLS_ENTRY_TITLE,
|
||||||
YAML_KEY_DEFAULT_POST_ACTION,
|
YAML_KEY_DEFAULT_POST_ACTION,
|
||||||
|
YAML_KEY_DENYLIST,
|
||||||
YAML_KEY_POST_ACTIONS,
|
YAML_KEY_POST_ACTIONS,
|
||||||
)
|
)
|
||||||
from .websocket_api import async_register_commands
|
from .websocket_api import async_register_commands
|
||||||
@@ -80,6 +81,8 @@ SERVICE_EDIT_YAML_CONFIG = "edit_yaml_config"
|
|||||||
SERVICE_GET_CALLER_TOKEN = "get_caller_token"
|
SERVICE_GET_CALLER_TOKEN = "get_caller_token"
|
||||||
SERVICE_GET_ALLOWED_PATHS = "get_allowed_paths"
|
SERVICE_GET_ALLOWED_PATHS = "get_allowed_paths"
|
||||||
SERVICE_SET_ALLOWED_PATHS = "set_allowed_paths"
|
SERVICE_SET_ALLOWED_PATHS = "set_allowed_paths"
|
||||||
|
SERVICE_GET_EXTRA_YAML_KEYS = "get_extra_yaml_keys"
|
||||||
|
SERVICE_SET_EXTRA_YAML_KEYS = "set_extra_yaml_keys"
|
||||||
# Read-only access to pre-#1579 YAML backups in .ha_mcp_tools_backups/, so the
|
# Read-only access to pre-#1579 YAML backups in .ha_mcp_tools_backups/, so the
|
||||||
# shared edits-backup interface can list/view/diff/restore them (#1579). These
|
# shared edits-backup interface can list/view/diff/restore them (#1579). These
|
||||||
# historical artifacts predate the fold into the shared store; new writes no
|
# historical artifacts predate the fold into the shared store; new writes no
|
||||||
@@ -105,6 +108,18 @@ _ALLOWED_PATHS_STORAGE_KEY = f"{DOMAIN}_allowed_paths"
|
|||||||
_ALLOWED_PATHS_STORAGE_VERSION = 1
|
_ALLOWED_PATHS_STORAGE_VERSION = 1
|
||||||
_HASS_DATA_ALLOWED_PATHS_KEY = "allowed_paths"
|
_HASS_DATA_ALLOWED_PATHS_KEY = "allowed_paths"
|
||||||
|
|
||||||
|
# User-configurable extra YAML write keys (#1887). Same store-per-concern
|
||||||
|
# rationale as the allowed paths above: a separate Store, loaded into hass.data
|
||||||
|
# at setup and updated in place by set_extra_yaml_keys so enforcement picks up
|
||||||
|
# changes with no HA restart. This is the component-owned half of the setting;
|
||||||
|
# the ha-mcp server also carries its own HA_MCP_EXTRA_YAML_KEYS, and the
|
||||||
|
# effective write allowlist is the union of the two (the server reads this store
|
||||||
|
# via get_extra_yaml_keys). YAML_KEY_DENYLIST members are stripped on the way in
|
||||||
|
# and re-checked at enforcement, so the store can never widen the deny floor.
|
||||||
|
_EXTRA_YAML_KEYS_STORAGE_KEY = f"{DOMAIN}_extra_yaml_keys"
|
||||||
|
_EXTRA_YAML_KEYS_STORAGE_VERSION = 1
|
||||||
|
_HASS_DATA_EXTRA_YAML_KEYS_KEY = "extra_yaml_keys"
|
||||||
|
|
||||||
# Service schemas
|
# Service schemas
|
||||||
SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
|
SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -130,6 +145,15 @@ SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("disabled_packages_keys", default=list): vol.All(
|
vol.Optional("disabled_packages_keys", default=list): vol.All(
|
||||||
cv.ensure_list, [cv.string]
|
cv.ensure_list, [cv.string]
|
||||||
),
|
),
|
||||||
|
# Caller-provided extra top-level keys the operator has opted into
|
||||||
|
# on top of ALLOWED_YAML_KEYS (#1887). Additive only: the handler
|
||||||
|
# filters YAML_KEY_DENYLIST out before use, so a caller cannot
|
||||||
|
# unlock a trust-boundary key by sending it here. Empty list (the
|
||||||
|
# default) means the built-in allowlist applies unchanged, which
|
||||||
|
# is also what a caller that predates this field produces.
|
||||||
|
vol.Optional("extra_allowed_keys", default=list): vol.All(
|
||||||
|
cv.ensure_list, [cv.string]
|
||||||
|
),
|
||||||
# Two-step preview/confirm flow (#1720). Both optional with
|
# Two-step preview/confirm flow (#1720). Both optional with
|
||||||
# old-behavior defaults so a pre-confirm-flow server (which never
|
# old-behavior defaults so a pre-confirm-flow server (which never
|
||||||
# sends them) still gets an immediate write.
|
# sends them) still gets an immediate write.
|
||||||
@@ -208,6 +232,24 @@ SERVICE_SET_ALLOWED_PATHS_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# get_extra_yaml_keys / set_extra_yaml_keys back the component's own options
|
||||||
|
# flow and the ha-mcp settings UI (#1887). Both are caller-token + admin gated,
|
||||||
|
# matching the allowed-paths pair. set_extra_yaml_keys receives the FULL
|
||||||
|
# replacement list; the handler strips whitespace/empties and drops any
|
||||||
|
# YAML_KEY_DENYLIST member, reporting drops in ``rejected``.
|
||||||
|
SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("keys", default=list): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
SERVICE_LIST_LEGACY_BACKUPS_SCHEMA = vol.Schema(
|
SERVICE_LIST_LEGACY_BACKUPS_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||||||
@@ -335,6 +377,128 @@ async def _save_allowed_paths(hass: HomeAssistant, paths: list[str]) -> None:
|
|||||||
await store.async_save({"paths": paths})
|
await store.async_save({"paths": paths})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_extra_yaml_keys(raw: Any) -> tuple[list[str], list[Any]]:
|
||||||
|
"""Clean a candidate extra-YAML-keys list into ``(kept, dropped)`` (#1887).
|
||||||
|
|
||||||
|
Mirrors the server's ``parse_extra_yaml_write_keys`` (strip whitespace, drop
|
||||||
|
empties, dedup, sort) and additionally drops any ``YAML_KEY_DENYLIST``
|
||||||
|
member, so the component store can never widen the deny floor. Non-string
|
||||||
|
and blank entries are dropped too. ``dropped`` collects every rejected
|
||||||
|
entry for reporting/logging. Case is preserved: HA's own top-level domain
|
||||||
|
lookup is exact-case, so a mis-cased key never reaches a real integration.
|
||||||
|
"""
|
||||||
|
kept: list[str] = []
|
||||||
|
dropped: list[Any] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for entry in raw if isinstance(raw, list) else []:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
dropped.append(entry)
|
||||||
|
continue
|
||||||
|
key = entry.strip()
|
||||||
|
if not key or key in YAML_KEY_DENYLIST:
|
||||||
|
dropped.append(entry)
|
||||||
|
continue
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
kept.append(key)
|
||||||
|
return sorted(kept), dropped
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_extra_yaml_keys(hass: HomeAssistant) -> list[str]:
|
||||||
|
"""Return the persisted user-configurable extra YAML write keys (#1887).
|
||||||
|
|
||||||
|
Fail-safe like :func:`_load_allowed_paths`: a corrupt/unreadable or
|
||||||
|
hand-edited store never propagates out of ``async_setup_entry``. Every entry
|
||||||
|
is re-validated through :func:`_normalize_extra_yaml_keys`, so a denylisted
|
||||||
|
or malformed key can never load into ``hass.data`` (defense in depth - the
|
||||||
|
deny floor is also re-checked at enforcement). Empty list on first run or a
|
||||||
|
malformed store.
|
||||||
|
"""
|
||||||
|
store: Store = Store(
|
||||||
|
hass, _EXTRA_YAML_KEYS_STORAGE_VERSION, _EXTRA_YAML_KEYS_STORAGE_KEY
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = await store.async_load()
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ha_mcp_tools: could not load the extra-YAML-keys store; ignoring "
|
||||||
|
"it and granting no extra write keys.",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
raw = data.get("keys")
|
||||||
|
if raw is not None and not isinstance(raw, list):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ha_mcp_tools extra-YAML-keys store is malformed (keys is %s, "
|
||||||
|
"expected list); ignoring it.",
|
||||||
|
type(raw).__name__,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
kept, dropped = _normalize_extra_yaml_keys(raw)
|
||||||
|
if dropped:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ha_mcp_tools: dropped %d invalid entr%s from the persisted "
|
||||||
|
"extra-YAML-keys store: %r",
|
||||||
|
len(dropped),
|
||||||
|
"y" if len(dropped) == 1 else "ies",
|
||||||
|
dropped,
|
||||||
|
)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_extra_yaml_keys(hass: HomeAssistant, keys: list[str]) -> None:
|
||||||
|
"""Persist the user-configurable extra YAML write keys to .storage."""
|
||||||
|
store: Store = Store(
|
||||||
|
hass, _EXTRA_YAML_KEYS_STORAGE_VERSION, _EXTRA_YAML_KEYS_STORAGE_KEY
|
||||||
|
)
|
||||||
|
await store.async_save({"keys": keys})
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_allowed_paths(
|
||||||
|
hass: HomeAssistant, raw_paths: Any
|
||||||
|
) -> tuple[list[str], list[Any]]:
|
||||||
|
"""Normalize, persist, and hot-swap the extra directories (#1567, #1887).
|
||||||
|
|
||||||
|
Shared by the ``set_allowed_paths`` service and the tools-entry options flow
|
||||||
|
so both edit the store through one validated path. Each entry runs through
|
||||||
|
:func:`_normalize_extra_dir`; traversal / out-of-config / deny-floor entries
|
||||||
|
are dropped into ``rejected``. Persists to .storage and updates hass.data so
|
||||||
|
enforcement applies live. Returns ``(kept, rejected)``.
|
||||||
|
"""
|
||||||
|
config_dir = Path(hass.config.config_dir)
|
||||||
|
normalized: list[str] = []
|
||||||
|
rejected: list[Any] = []
|
||||||
|
for entry in raw_paths if isinstance(raw_paths, list) else []:
|
||||||
|
norm = (
|
||||||
|
_normalize_extra_dir(entry, config_dir) if isinstance(entry, str) else None
|
||||||
|
)
|
||||||
|
if norm is None:
|
||||||
|
rejected.append(entry)
|
||||||
|
elif norm not in normalized:
|
||||||
|
normalized.append(norm)
|
||||||
|
await _save_allowed_paths(hass, normalized)
|
||||||
|
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
|
||||||
|
return normalized, rejected
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_extra_yaml_keys(
|
||||||
|
hass: HomeAssistant, raw_keys: Any
|
||||||
|
) -> tuple[list[str], list[Any]]:
|
||||||
|
"""Normalize, persist, and hot-swap the extra YAML write keys (#1887).
|
||||||
|
|
||||||
|
Shared by the ``set_extra_yaml_keys`` service and the tools-entry options
|
||||||
|
flow. Delegates validation to :func:`_normalize_extra_yaml_keys` (strip,
|
||||||
|
dedup, sort, drop denylist). Persists to .storage and updates hass.data so
|
||||||
|
enforcement applies live. Returns ``(kept, rejected)``.
|
||||||
|
"""
|
||||||
|
normalized, rejected = _normalize_extra_yaml_keys(raw_keys)
|
||||||
|
await _save_extra_yaml_keys(hass, normalized)
|
||||||
|
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_EXTRA_YAML_KEYS_KEY] = normalized
|
||||||
|
return normalized, rejected
|
||||||
|
|
||||||
|
|
||||||
def _unified_diff(before: str, after: str, rel_path: str, max_lines: int = 200) -> str:
|
def _unified_diff(before: str, after: str, rel_path: str, max_lines: int = 200) -> str:
|
||||||
"""Unified diff of a prospective write, capped for response size."""
|
"""Unified diff of a prospective write, capped for response size."""
|
||||||
lines = list(
|
lines = list(
|
||||||
@@ -2095,7 +2259,10 @@ def _build_edit_yaml_config_handler(
|
|||||||
|
|
||||||
# Parse and validate yaml_path (replaces the old ALLOWED_YAML_KEYS check)
|
# Parse and validate yaml_path (replaces the old ALLOWED_YAML_KEYS check)
|
||||||
kind, path_parts, path_err = _parse_and_validate_yaml_path(
|
kind, path_parts, path_err = _parse_and_validate_yaml_path(
|
||||||
yaml_path, is_package=is_package, is_theme=is_theme
|
yaml_path,
|
||||||
|
is_package=is_package,
|
||||||
|
is_theme=is_theme,
|
||||||
|
extra_allowed_keys=_effective_extra_allowed_keys(hass, call),
|
||||||
)
|
)
|
||||||
if path_err is not None:
|
if path_err is not None:
|
||||||
return {"success": False, "error": path_err}
|
return {"success": False, "error": path_err}
|
||||||
@@ -2218,18 +2385,59 @@ def _validate_lovelace_dashboard_path(
|
|||||||
return "lovelace_dashboard", parts, None
|
return "lovelace_dashboard", parts, None
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_extra_allowed_keys(call: ServiceCall) -> frozenset[str]:
|
||||||
|
"""Return the operator-configured extra write keys for this call (#1887).
|
||||||
|
|
||||||
|
``YAML_KEY_DENYLIST`` members are dropped here so they can never widen the
|
||||||
|
``allowed`` set that the generic "not in the allowed list" rejection lists
|
||||||
|
for some *other* invalid key. A direct write to a denied key does not rely
|
||||||
|
on this drop: ``_parse_and_validate_yaml_path`` checks the denylist first
|
||||||
|
and returns the categorical floor message before any allow-set is
|
||||||
|
consulted. Keys already covered by the built-in sets are harmless
|
||||||
|
duplicates and stay.
|
||||||
|
"""
|
||||||
|
return frozenset(
|
||||||
|
key
|
||||||
|
for key in call.data.get("extra_allowed_keys", [])
|
||||||
|
if key and key not in YAML_KEY_DENYLIST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_extra_allowed_keys(
|
||||||
|
hass: HomeAssistant, call: ServiceCall
|
||||||
|
) -> frozenset[str]:
|
||||||
|
"""Union the per-call wire keys with the component-stored keys (#1887).
|
||||||
|
|
||||||
|
The ha-mcp server passes its own ``HA_MCP_EXTRA_YAML_KEYS`` on the wire; the
|
||||||
|
component's own options flow / settings store contributes
|
||||||
|
:func:`_current_extra_yaml_keys`. Both sources are denylist-filtered - the
|
||||||
|
wire in :func:`_caller_extra_allowed_keys`, the store on save/load - and
|
||||||
|
:func:`_parse_and_validate_yaml_path` re-checks the deny floor regardless,
|
||||||
|
so the union can never lift a forbidden key. The store side is filtered
|
||||||
|
again here purely as defense in depth.
|
||||||
|
"""
|
||||||
|
stored = frozenset(
|
||||||
|
key for key in _current_extra_yaml_keys(hass) if key not in YAML_KEY_DENYLIST
|
||||||
|
)
|
||||||
|
return _caller_extra_allowed_keys(call) | stored
|
||||||
|
|
||||||
|
|
||||||
def _parse_and_validate_yaml_path(
|
def _parse_and_validate_yaml_path(
|
||||||
yaml_path: str,
|
yaml_path: str,
|
||||||
*,
|
*,
|
||||||
is_package: bool = False,
|
is_package: bool = False,
|
||||||
is_theme: bool = False,
|
is_theme: bool = False,
|
||||||
|
extra_allowed_keys: frozenset[str] = frozenset(),
|
||||||
) -> tuple[str, tuple[str, ...], str | None]:
|
) -> tuple[str, tuple[str, ...], str | None]:
|
||||||
"""Parse and validate a yaml_path argument.
|
"""Parse and validate a yaml_path argument.
|
||||||
|
|
||||||
Three accepted shapes:
|
Three accepted shapes:
|
||||||
1. Single segment in ALLOWED_YAML_KEYS -> kind='single'
|
1. Single segment in ALLOWED_YAML_KEYS -> kind='single'
|
||||||
When ``is_package=True``, single segments in PACKAGES_ONLY_YAML_KEYS
|
When ``is_package=True``, single segments in PACKAGES_ONLY_YAML_KEYS
|
||||||
(automation, script, scene) are also accepted.
|
(automation, script, scene) are also accepted. ``extra_allowed_keys``
|
||||||
|
adds operator-opted-in keys on top of ALLOWED_YAML_KEYS (#1887); it
|
||||||
|
overrides neither ``YAML_KEY_DENYLIST`` (filtered out before it gets
|
||||||
|
here) nor the packages-only restriction.
|
||||||
2. Exactly 'lovelace.dashboards.<url_path>' -> kind='lovelace_dashboard'
|
2. Exactly 'lovelace.dashboards.<url_path>' -> kind='lovelace_dashboard'
|
||||||
3. Single segment theme name (no dots) when ``is_theme=True`` -> kind='theme'
|
3. Single segment theme name (no dots) when ``is_theme=True`` -> kind='theme'
|
||||||
|
|
||||||
@@ -2256,13 +2464,41 @@ def _parse_and_validate_yaml_path(
|
|||||||
# Shape 1: single key
|
# Shape 1: single key
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
key = parts[0]
|
key = parts[0]
|
||||||
|
# The deny floor is checked before every single-key accept branch, so
|
||||||
|
# it holds even if a denied key is ever added to one of the allow
|
||||||
|
# sets. It deliberately does not cover the theme branch above: under
|
||||||
|
# ``is_theme`` the segment names a file in themes/, not a top-level
|
||||||
|
# configuration key, so the same word carries no trust-boundary
|
||||||
|
# meaning there.
|
||||||
|
if key in YAML_KEY_DENYLIST:
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
(),
|
||||||
|
(
|
||||||
|
f"Key '{yaml_path}' can never be edited through this "
|
||||||
|
"service: it redefines Home Assistant's own trust "
|
||||||
|
"boundary (authentication, proxy/CORS handling, or "
|
||||||
|
"frontend module loading). This floor cannot be lifted "
|
||||||
|
"by the extra-write-keys setting. Edit it by hand if "
|
||||||
|
"you really need to change it."
|
||||||
|
),
|
||||||
|
)
|
||||||
if key in ALLOWED_YAML_KEYS:
|
if key in ALLOWED_YAML_KEYS:
|
||||||
return "single", parts, None
|
return "single", parts, None
|
||||||
if is_package and key in PACKAGES_ONLY_YAML_KEYS:
|
if is_package and key in PACKAGES_ONLY_YAML_KEYS:
|
||||||
return "single", parts, None
|
return "single", parts, None
|
||||||
|
# Operator extra keys widen ALLOWED_YAML_KEYS only. They deliberately
|
||||||
|
# do NOT lift the packages-only restriction: those keys reach
|
||||||
|
# packages/*.yaml through the branch above (still governed by their
|
||||||
|
# per-key toggle) and stay rejected in configuration.yaml, so the
|
||||||
|
# storage-mode/YAML-mode collision guarantee holds however the
|
||||||
|
# operator fills the setting.
|
||||||
|
if key in extra_allowed_keys and key not in PACKAGES_ONLY_YAML_KEYS:
|
||||||
|
return "single", parts, None
|
||||||
# Reaching here means the key was not accepted. If it is a
|
# Reaching here means the key was not accepted. If it is a
|
||||||
# PACKAGES_ONLY key, we know is_package=False (otherwise the
|
# PACKAGES_ONLY key, we know is_package=False (the packages branch
|
||||||
# preceding branch would have returned) — emit the targeted
|
# would have returned, and the extra-keys branch excludes them
|
||||||
|
# precisely so this guidance still fires) – emit the targeted
|
||||||
# "move it to a package file" guidance instead of the generic
|
# "move it to a package file" guidance instead of the generic
|
||||||
# allowlist dump below.
|
# allowlist dump below.
|
||||||
if key in PACKAGES_ONLY_YAML_KEYS:
|
if key in PACKAGES_ONLY_YAML_KEYS:
|
||||||
@@ -2278,9 +2514,9 @@ def _parse_and_validate_yaml_path(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
allowed = (
|
allowed = (
|
||||||
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS
|
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS | extra_allowed_keys
|
||||||
if is_package
|
if is_package
|
||||||
else ALLOWED_YAML_KEYS
|
else ALLOWED_YAML_KEYS | extra_allowed_keys
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
"",
|
"",
|
||||||
@@ -2421,6 +2657,16 @@ def _current_extra_dirs(hass: HomeAssistant) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _current_extra_yaml_keys(hass: HomeAssistant) -> list[str]:
|
||||||
|
"""Return the live component-configured extra YAML write keys from hass.data."""
|
||||||
|
domain_data = hass.data.get(DOMAIN)
|
||||||
|
if isinstance(domain_data, dict):
|
||||||
|
keys = domain_data.get(_HASS_DATA_EXTRA_YAML_KEYS_KEY)
|
||||||
|
if isinstance(keys, list):
|
||||||
|
return keys
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _build_list_files_handler(
|
def _build_list_files_handler(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
@@ -2520,9 +2766,11 @@ async def _shape_read_file_response(
|
|||||||
# Apply special handling for specific files
|
# Apply special handling for specific files
|
||||||
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
|
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
|
||||||
|
|
||||||
# Mask secrets.yaml
|
# Mask secrets.yaml. Offloaded because the first make_yaml() call on a
|
||||||
|
# thread constructs a ruamel YAML instance, whose plugin discovery globs
|
||||||
|
# the site-packages tree — blocking work that must stay off the loop.
|
||||||
if normalized == "secrets.yaml":
|
if normalized == "secrets.yaml":
|
||||||
content = _mask_secrets_content(content)
|
content = await hass.async_add_executor_job(_mask_secrets_content, content)
|
||||||
|
|
||||||
# Apply tail for log files
|
# Apply tail for log files
|
||||||
if normalized == "home-assistant.log":
|
if normalized == "home-assistant.log":
|
||||||
@@ -2845,8 +3093,12 @@ def _build_get_caller_token_handler(
|
|||||||
# uses everywhere else.
|
# uses everywhere else.
|
||||||
try:
|
try:
|
||||||
integration = await async_get_integration(hass, DOMAIN)
|
integration = await async_get_integration(hass, DOMAIN)
|
||||||
|
if integration.version is None:
|
||||||
|
# Reads as None rather than raising; an unreadable version and
|
||||||
|
# an absent one deserve the same answer.
|
||||||
|
raise ValueError("the manifest carries no version")
|
||||||
version = str(integration.version)
|
version = str(integration.version)
|
||||||
except Exception as exc: # pragma: no cover — manifest sanity
|
except Exception as exc:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Could not read ha_mcp_tools manifest version for "
|
"Could not read ha_mcp_tools manifest version for "
|
||||||
"get_caller_token response: %s",
|
"get_caller_token response: %s",
|
||||||
@@ -2906,7 +3158,6 @@ def _build_set_allowed_paths_handler(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
"""Build the handle_set_allowed_paths service handler."""
|
"""Build the handle_set_allowed_paths service handler."""
|
||||||
config_dir = Path(hass.config.config_dir)
|
|
||||||
|
|
||||||
async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse:
|
async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse:
|
||||||
"""Replace the user-configurable extra directories (issues #1567, #1586).
|
"""Replace the user-configurable extra directories (issues #1567, #1586).
|
||||||
@@ -2929,18 +3180,9 @@ def _build_set_allowed_paths_handler(
|
|||||||
"error": "ha_mcp_tools.set_allowed_paths requires admin auth.",
|
"error": "ha_mcp_tools.set_allowed_paths requires admin auth.",
|
||||||
"paths": [],
|
"paths": [],
|
||||||
}
|
}
|
||||||
raw_paths = call.data.get("paths", [])
|
normalized, rejected = await _apply_allowed_paths(
|
||||||
normalized: list[str] = []
|
hass, call.data.get("paths", [])
|
||||||
rejected: list[str] = []
|
)
|
||||||
for entry in raw_paths:
|
|
||||||
norm = _normalize_extra_dir(entry, config_dir)
|
|
||||||
if norm is None:
|
|
||||||
rejected.append(entry)
|
|
||||||
elif norm not in normalized:
|
|
||||||
normalized.append(norm)
|
|
||||||
|
|
||||||
await _save_allowed_paths(hass, normalized)
|
|
||||||
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Updated ha_mcp_tools custom filesystem directories: %s (%d rejected)",
|
"Updated ha_mcp_tools custom filesystem directories: %s (%d rejected)",
|
||||||
normalized,
|
normalized,
|
||||||
@@ -2955,6 +3197,77 @@ def _build_set_allowed_paths_handler(
|
|||||||
return handle_set_allowed_paths
|
return handle_set_allowed_paths
|
||||||
|
|
||||||
|
|
||||||
|
def _build_get_extra_yaml_keys_handler(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
|
"""Build the handle_get_extra_yaml_keys service handler (#1887)."""
|
||||||
|
|
||||||
|
async def handle_get_extra_yaml_keys(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Return the component-configured extra YAML write keys plus the
|
||||||
|
non-overridable deny floor.
|
||||||
|
|
||||||
|
Backs the component's own options flow and the ha-mcp settings UI, and
|
||||||
|
is how the server reads this store to union it with its own
|
||||||
|
``HA_MCP_EXTRA_YAML_KEYS``. Caller-token + admin gated, matching
|
||||||
|
get_allowed_paths.
|
||||||
|
"""
|
||||||
|
if not _caller_token_ok(hass, call):
|
||||||
|
return _unauthorized_response(SERVICE_GET_EXTRA_YAML_KEYS, keys=[])
|
||||||
|
if not await _caller_is_admin(hass, call):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error_code": "unauthorized",
|
||||||
|
"error": "ha_mcp_tools.get_extra_yaml_keys requires admin auth.",
|
||||||
|
"keys": [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"keys": _current_extra_yaml_keys(hass),
|
||||||
|
"deny_floor": sorted(YAML_KEY_DENYLIST),
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle_get_extra_yaml_keys
|
||||||
|
|
||||||
|
|
||||||
|
def _build_set_extra_yaml_keys_handler(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
|
"""Build the handle_set_extra_yaml_keys service handler (#1887)."""
|
||||||
|
|
||||||
|
async def handle_set_extra_yaml_keys(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Replace the component-configured extra YAML write keys.
|
||||||
|
|
||||||
|
Receives the FULL replacement list. Each entry is stripped and
|
||||||
|
validated; blanks and ``YAML_KEY_DENYLIST`` members are dropped and
|
||||||
|
reported in ``rejected``. Persists to .storage AND updates hass.data so
|
||||||
|
enforcement applies live with no HA restart. Caller-token + admin gated.
|
||||||
|
"""
|
||||||
|
if not _caller_token_ok(hass, call):
|
||||||
|
return _unauthorized_response(SERVICE_SET_EXTRA_YAML_KEYS, keys=[])
|
||||||
|
if not await _caller_is_admin(hass, call):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error_code": "unauthorized",
|
||||||
|
"error": "ha_mcp_tools.set_extra_yaml_keys requires admin auth.",
|
||||||
|
"keys": [],
|
||||||
|
}
|
||||||
|
normalized, rejected = await _apply_extra_yaml_keys(
|
||||||
|
hass, call.data.get("keys", [])
|
||||||
|
)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Updated ha_mcp_tools extra YAML write keys: %s (%d rejected)",
|
||||||
|
normalized,
|
||||||
|
len(rejected),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"keys": normalized,
|
||||||
|
"rejected": rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle_set_extra_yaml_keys
|
||||||
|
|
||||||
|
|
||||||
def _build_list_legacy_backups_handler(
|
def _build_list_legacy_backups_handler(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||||||
@@ -3063,6 +3376,13 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
|||||||
_HASS_DATA_ALLOWED_PATHS_KEY
|
_HASS_DATA_ALLOWED_PATHS_KEY
|
||||||
] = await _load_allowed_paths(hass)
|
] = await _load_allowed_paths(hass)
|
||||||
|
|
||||||
|
# Load the component-configured extra YAML write keys (#1887) into hass.data
|
||||||
|
# so enforcement reads them with no I/O. set_extra_yaml_keys updates this in
|
||||||
|
# place, so changes apply live (no HA restart).
|
||||||
|
hass.data.setdefault(DOMAIN, {})[
|
||||||
|
_HASS_DATA_EXTRA_YAML_KEYS_KEY
|
||||||
|
] = await _load_extra_yaml_keys(hass)
|
||||||
|
|
||||||
# One-time migration of pre-fix YAML backups out of the publicly-served
|
# One-time migration of pre-fix YAML backups out of the publicly-served
|
||||||
# www/ directory (GHSA-g39v-cvjh-8fpf). Wrapped so a migration failure
|
# www/ directory (GHSA-g39v-cvjh-8fpf). Wrapped so a migration failure
|
||||||
# cannot prevent the integration from loading — the integration's
|
# cannot prevent the integration from loading — the integration's
|
||||||
@@ -3132,6 +3452,8 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
|||||||
handle_get_caller_token = _build_get_caller_token_handler(hass)
|
handle_get_caller_token = _build_get_caller_token_handler(hass)
|
||||||
handle_get_allowed_paths = _build_get_allowed_paths_handler(hass)
|
handle_get_allowed_paths = _build_get_allowed_paths_handler(hass)
|
||||||
handle_set_allowed_paths = _build_set_allowed_paths_handler(hass)
|
handle_set_allowed_paths = _build_set_allowed_paths_handler(hass)
|
||||||
|
handle_get_extra_yaml_keys = _build_get_extra_yaml_keys_handler(hass)
|
||||||
|
handle_set_extra_yaml_keys = _build_set_extra_yaml_keys_handler(hass)
|
||||||
handle_list_legacy_backups = _build_list_legacy_backups_handler(hass)
|
handle_list_legacy_backups = _build_list_legacy_backups_handler(hass)
|
||||||
handle_read_legacy_backup = _build_read_legacy_backup_handler(hass)
|
handle_read_legacy_backup = _build_read_legacy_backup_handler(hass)
|
||||||
|
|
||||||
@@ -3200,6 +3522,22 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
|||||||
supports_response=SupportsResponse.ONLY,
|
supports_response=SupportsResponse.ONLY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_GET_EXTRA_YAML_KEYS,
|
||||||
|
handle_get_extra_yaml_keys,
|
||||||
|
schema=SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_EXTRA_YAML_KEYS,
|
||||||
|
handle_set_extra_yaml_keys,
|
||||||
|
schema=SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_LIST_LEGACY_BACKUPS,
|
SERVICE_LIST_LEGACY_BACKUPS,
|
||||||
@@ -3242,10 +3580,17 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
|
|||||||
component_version = COMPONENT_VERSION
|
component_version = COMPONENT_VERSION
|
||||||
try:
|
try:
|
||||||
integration = await async_get_integration(hass, DOMAIN)
|
integration = await async_get_integration(hass, DOMAIN)
|
||||||
|
if integration.version is None:
|
||||||
|
# A manifest without a version reads as None rather than raising,
|
||||||
|
# and ``str()`` would put the literal "None" on the device.
|
||||||
|
raise ValueError("the manifest carries no version")
|
||||||
component_version = str(integration.version)
|
component_version = str(integration.version)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Could not read the component version for the tools device: %s", err
|
"Could not read the component version for the tools device, using "
|
||||||
|
"the compiled-in %s: %s",
|
||||||
|
COMPONENT_VERSION,
|
||||||
|
err,
|
||||||
)
|
)
|
||||||
dr.async_get(hass).async_get_or_create(
|
dr.async_get(hass).async_get_or_create(
|
||||||
config_entry_id=entry.entry_id,
|
config_entry_id=entry.entry_id,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -20,11 +20,13 @@ options flow, and the tools entry gets a light informational options flow
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
|
ConfigEntryState,
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
@@ -106,6 +108,229 @@ _SERVER_UNIQUE_ID = f"{DOMAIN}-server"
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# The options form's prose is assembled here rather than in strings.json,
|
||||||
|
# because which sentences appear depends on runtime state. Keeping the text
|
||||||
|
# itself in the ``common`` catalog means the assembled paragraphs follow the
|
||||||
|
# system-configured language instead of being English inside an otherwise
|
||||||
|
# translated form. These are the English source strings and the fallback: if
|
||||||
|
# the language is unreadable or the catalog cannot be loaded, the form still
|
||||||
|
# renders, in English, exactly as it did before.
|
||||||
|
#
|
||||||
|
# Kept identical to ``strings.json``'s ``common`` block, keys and values —
|
||||||
|
# asserted by ``test_common_fallbacks_mirror_strings_json`` in
|
||||||
|
# tests/src/unit/test_config_flow.py, because a fallback that has drifted
|
||||||
|
# from the source shows different English than every catalog.
|
||||||
|
_COMMON_FALLBACKS: dict[str, str] = {
|
||||||
|
"panel_hint": (
|
||||||
|
"Open the [HA-MCP settings panel](/ha-mcp) for tool management and "
|
||||||
|
"server settings."
|
||||||
|
),
|
||||||
|
"version_line": (
|
||||||
|
"Component {component_version} - "
|
||||||
|
"Server ha-mcp {server_version} ({channel} channel)"
|
||||||
|
),
|
||||||
|
"version_unknown": "unknown",
|
||||||
|
"version_not_installed": "not installed yet",
|
||||||
|
"tools_module_installed": (
|
||||||
|
"Beta/advanced file & YAML tools module (optional): Installed"
|
||||||
|
),
|
||||||
|
"tools_module_not_loaded": (
|
||||||
|
"Beta/advanced file & YAML tools module (optional): Installed "
|
||||||
|
'but not loaded — enable or reload the "HA-MCP File & YAML '
|
||||||
|
"Tools\" entry on this integration's page"
|
||||||
|
),
|
||||||
|
"tools_module_not_installed": (
|
||||||
|
"Beta/advanced file & YAML tools module (optional): Not installed — "
|
||||||
|
'press "Add entry" on this integration\'s page and choose '
|
||||||
|
'"HA-MCP File & YAML Tools" to add it'
|
||||||
|
),
|
||||||
|
"connect_urls_pending": (
|
||||||
|
"The connect URLs appear here (and in the Home Assistant log) "
|
||||||
|
"once the server has started."
|
||||||
|
),
|
||||||
|
"connect_urls_label": "Connect URL(s):",
|
||||||
|
"connect_webhook_disabled": (
|
||||||
|
"Remote access via webhook is disabled (local-only mode)."
|
||||||
|
),
|
||||||
|
"connect_direct_access": "Direct access from the Home Assistant machine: {url}",
|
||||||
|
"connect_remote_url": "Remote connect URL: {url}",
|
||||||
|
"connect_local_lan": 'Local/LAN (when Network access is "Local network"): {url}',
|
||||||
|
"oauth_select_legacy_mode": (
|
||||||
|
"Set Authentication mode to legacy OAuth above and save to "
|
||||||
|
"generate a Client ID and Client Secret."
|
||||||
|
),
|
||||||
|
"oauth_creds_pending": (
|
||||||
|
"The Client ID and Client Secret appear here once the server has started."
|
||||||
|
),
|
||||||
|
"oauth_not_serving": (
|
||||||
|
"Legacy OAuth is not serving these yet — restart Home Assistant "
|
||||||
|
"when it asks you to, to activate them."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fill(common: dict[str, str], key: str, /, **values: str) -> str:
|
||||||
|
"""Return the ``common`` string ``key`` with ``values`` substituted.
|
||||||
|
|
||||||
|
Placeholder parity is asserted in tests/src/unit/test_locale_parity.py, but
|
||||||
|
a catalog is data: one malformed brace, or a placeholder the parity check
|
||||||
|
cannot see (``{component_version.major}`` reads as no placeholder at all),
|
||||||
|
would otherwise take the whole options form down. The English source is a
|
||||||
|
module constant, so formatting it after a failure needs no second guard.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return common[key].format(**values)
|
||||||
|
except Exception as err:
|
||||||
|
# Names both causes: a catalog string this caller cannot fill, or a
|
||||||
|
# caller passing values the template never declared. The second is our
|
||||||
|
# bug and crashes on the English constant below, so the log line has to
|
||||||
|
# point at the caller rather than blame the translator.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Unusable %s template (%r) — bad catalog string or wrong caller "
|
||||||
|
"arguments; using the English source: %s",
|
||||||
|
key,
|
||||||
|
common.get(key),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return _COMMON_FALLBACKS[key].format(**values)
|
||||||
|
|
||||||
|
|
||||||
|
# Scripts that set their own inter-sentence spacing: the full-width punctuation
|
||||||
|
# they end on already carries it, so an ASCII space after it renders as a gap.
|
||||||
|
#
|
||||||
|
# Keyed off the language, not off the last character. Sniffing glyphs got it
|
||||||
|
# wrong in both directions: U+201D (”) is Simplified Chinese's closing quote and
|
||||||
|
# was removed as "Latin", while 「」『』 are the traditional forms zh-Hans does
|
||||||
|
# not use and were kept.
|
||||||
|
#
|
||||||
|
# ``ko`` is deliberately absent. Korean separates words with ASCII spaces and
|
||||||
|
# ends sentences on an ASCII full stop, so a future ``ko`` catalog wants the
|
||||||
|
# separator exactly like a Latin one — the full-width rationale above simply
|
||||||
|
# does not apply to it.
|
||||||
|
_NO_ASCII_SENTENCE_SPACE = frozenset({"zh", "ja"})
|
||||||
|
|
||||||
|
|
||||||
|
def _sentence_prefix(sentence: str, language: str, english: str) -> str:
|
||||||
|
"""Return ``sentence`` spaced to run into the prose that follows it.
|
||||||
|
|
||||||
|
Two inputs decide this, and each alone has already been wrong once. The
|
||||||
|
language names the script, which the last character cannot. But the
|
||||||
|
language does not promise the text follows it: core loads
|
||||||
|
``[en, <language>]`` and merges English first as the documented fallback
|
||||||
|
(``helpers/translation.py``), so an instance set to a language this
|
||||||
|
integration does not ship reads these sentences in English — and English
|
||||||
|
needs the ASCII separator whatever ``hass.config.language`` says. The same
|
||||||
|
holds for a shipped language whenever the catalog load degrades.
|
||||||
|
|
||||||
|
``english`` is the English source for this sentence; when the catalog
|
||||||
|
hands back exactly that, the rendered text is English and gets the space.
|
||||||
|
"""
|
||||||
|
if not sentence:
|
||||||
|
return sentence
|
||||||
|
if (
|
||||||
|
language.split("-", maxsplit=1)[0].lower() in _NO_ASCII_SENTENCE_SPACE
|
||||||
|
and sentence != english
|
||||||
|
):
|
||||||
|
return sentence
|
||||||
|
return f"{sentence} "
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_common_translations(
|
||||||
|
hass: HomeAssistant, language: str
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""core ``async_get_translations(hass, language, "common")``; test seam.
|
||||||
|
|
||||||
|
Mirrors the seam in ``websocket_api`` so the lookup can be replaced in
|
||||||
|
tests without reaching into Home Assistant's translation machinery.
|
||||||
|
"""
|
||||||
|
from homeassistant.helpers.translation import async_get_translations
|
||||||
|
|
||||||
|
result = await async_get_translations(hass, language, "common", {DOMAIN})
|
||||||
|
# Any Mapping, not just dict: core returns a plain dict today, but the
|
||||||
|
# mirrored seam in ``websocket_api`` accepts a Mapping, and narrowing it
|
||||||
|
# here would silently discard a whole catalog on a core-internal change.
|
||||||
|
if isinstance(result, Mapping):
|
||||||
|
return dict(result)
|
||||||
|
# Discarding a whole catalog is the same pure-English outcome as a failed
|
||||||
|
# load, so it gets the same visibility; the type is the only useful clue.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Ignoring the %s common translations: expected a Mapping, got %s",
|
||||||
|
language,
|
||||||
|
type(result).__name__,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _common_strings(hass: HomeAssistant | None) -> tuple[dict[str, str], str]:
|
||||||
|
"""Return the ``common`` catalog and the language it was fetched for.
|
||||||
|
|
||||||
|
``hass.config.language`` is the instance-wide language, not the profile
|
||||||
|
language of the administrator who opened the form — an options flow is
|
||||||
|
handed no requester language (Home Assistant's flow context carries
|
||||||
|
``source`` and ``entry_id`` only), so where the two differ this prose
|
||||||
|
follows the system setting while the surrounding form follows the user.
|
||||||
|
|
||||||
|
The language is returned rather than left for the caller to read again:
|
||||||
|
the sentence separator needs it, and two independent reads of the same
|
||||||
|
attribute can disagree about which catalog is actually in hand. Here they
|
||||||
|
cannot — this is the only place the attribute is read, and ``en`` is what
|
||||||
|
both the fallback strings and the returned language say when it is
|
||||||
|
unreadable.
|
||||||
|
|
||||||
|
Failure-proof like the hints it feeds: an unreadable language or a
|
||||||
|
failing lookup degrades to the English source strings rather than
|
||||||
|
breaking the options form.
|
||||||
|
"""
|
||||||
|
strings = dict(_COMMON_FALLBACKS)
|
||||||
|
configured = getattr(getattr(hass, "config", None), "language", None)
|
||||||
|
if hass is None or not isinstance(configured, str):
|
||||||
|
return strings, "en"
|
||||||
|
language = configured
|
||||||
|
try:
|
||||||
|
loaded = await _fetch_common_translations(hass, language)
|
||||||
|
except Exception as err:
|
||||||
|
# Warning, not debug: this is a degradation an administrator can see
|
||||||
|
# in the form (English paragraphs inside a translated page) and the
|
||||||
|
# broad ``except`` also covers an ImportError from the function-local
|
||||||
|
# core import — a permanent defect nobody would ever notice at debug.
|
||||||
|
# ``exc_info`` because the traceback is the only way to tell the two
|
||||||
|
# apart. Same level the ``websocket_api`` seam this mirrors uses.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not load the %s options-form translations, falling back to "
|
||||||
|
"English: %s",
|
||||||
|
language,
|
||||||
|
err,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return strings, language
|
||||||
|
|
||||||
|
prefix = f"component.{DOMAIN}.common."
|
||||||
|
translated = {
|
||||||
|
key.removeprefix(prefix): value
|
||||||
|
for key, value in loaded.items()
|
||||||
|
if key.startswith(prefix) and isinstance(value, str) and value
|
||||||
|
}
|
||||||
|
if not translated:
|
||||||
|
# Deliberately not ``if loaded and not translated``: core returns a
|
||||||
|
# single-component lookup straight from that component's cache entry
|
||||||
|
# (``_TranslationCache.get_cached``), so a category that was never
|
||||||
|
# built arrives as ``{}`` — which is exactly the developer error worth
|
||||||
|
# seeing, and an empty-``loaded`` condition would skip it. The merge is
|
||||||
|
# a silent no-op either way: the form renders pure English and no other
|
||||||
|
# check notices.
|
||||||
|
# Wording covers both ways to get here: a catalog that carries nothing
|
||||||
|
# under our prefix, and one the seam already discarded and warned about
|
||||||
|
# (where "carries no keys" would be untrue — there was no catalog).
|
||||||
|
_LOGGER.warning(
|
||||||
|
"No usable %s translations under %s, so the options form renders "
|
||||||
|
"its assembled prose in English",
|
||||||
|
language,
|
||||||
|
prefix,
|
||||||
|
)
|
||||||
|
strings.update(translated)
|
||||||
|
return strings, language
|
||||||
|
|
||||||
|
|
||||||
def _legacy_credentials_active(
|
def _legacy_credentials_active(
|
||||||
hass: HomeAssistant, client_id: str, client_secret: str, signing_key: str
|
hass: HomeAssistant, client_id: str, client_secret: str, signing_key: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -240,29 +465,70 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
|||||||
|
|
||||||
|
|
||||||
class HaMcpToolsInfoOptionsFlow(OptionsFlow):
|
class HaMcpToolsInfoOptionsFlow(OptionsFlow):
|
||||||
"""Options flow for the tools entry: a light informational form.
|
"""Options flow for the tools entry: edit the privileged services' config.
|
||||||
|
|
||||||
The tools services entry has nothing to configure yet, but aborting the
|
Surfaces the two operator-tunable sets the file/YAML tools honour - the
|
||||||
Configure dialog reads as an error. Show an empty-schema form that explains
|
extra read/write directories and the extra top-level YAML write keys - so
|
||||||
what the entry provides instead; submitting persists an empty options
|
they are reachable from the integration UI, not only the ha-mcp server's own
|
||||||
payload.
|
settings. Both live in the component's own .storage (get/set_allowed_paths
|
||||||
|
and get/set_extra_yaml_keys), so this screen and the server settings UI edit
|
||||||
|
the same source of truth, applied live with no restart. The deny floor is
|
||||||
|
non-overridable: traversal / out-of-config directories and denylisted keys
|
||||||
|
are dropped on save.
|
||||||
|
|
||||||
The form uses the ``tools_info`` step id, NOT ``init``: the server options
|
The form uses the ``tools_info`` step id, NOT ``init``: the server options
|
||||||
flow already owns ``options.step.init`` in strings.json, so a shared step id
|
flow already owns ``options.step.init`` in strings.json, so a shared step id
|
||||||
would collide. ``async_step_init`` is the required entry point (it renders
|
would collide. ``async_step_init`` renders the form; HA routes the form's
|
||||||
the form); HA routes the form's submit to ``async_step_tools_info``.
|
submit to ``async_step_tools_info``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _tools_form_schema(self) -> vol.Schema:
|
||||||
|
"""Build the form schema with the current stored values as defaults."""
|
||||||
|
from . import _current_extra_dirs, _current_extra_yaml_keys
|
||||||
|
|
||||||
|
current_dirs = _current_extra_dirs(self.hass)
|
||||||
|
current_keys = _current_extra_yaml_keys(self.hass)
|
||||||
|
return vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("allowed_dirs", default=current_dirs): SelectSelector(
|
||||||
|
SelectSelectorConfig(
|
||||||
|
options=current_dirs,
|
||||||
|
multiple=True,
|
||||||
|
custom_value=True,
|
||||||
|
mode=SelectSelectorMode.LIST,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Optional("extra_yaml_keys", default=current_keys): SelectSelector(
|
||||||
|
SelectSelectorConfig(
|
||||||
|
options=current_keys,
|
||||||
|
multiple=True,
|
||||||
|
custom_value=True,
|
||||||
|
mode=SelectSelectorMode.LIST,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Render the informational form under the ``tools_info`` step id."""
|
"""Render the editable form under the ``tools_info`` step id."""
|
||||||
return self.async_show_form(step_id="tools_info", data_schema=vol.Schema({}))
|
return self.async_show_form(
|
||||||
|
step_id="tools_info", data_schema=self._tools_form_schema()
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_tools_info(
|
async def async_step_tools_info(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Persist an empty options payload once the info form is submitted."""
|
"""Persist the edited directories and keys once the form is submitted."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="tools_info", data_schema=self._tools_form_schema()
|
||||||
|
)
|
||||||
|
from . import _apply_allowed_paths, _apply_extra_yaml_keys
|
||||||
|
|
||||||
|
await _apply_allowed_paths(self.hass, user_input.get("allowed_dirs", []))
|
||||||
|
await _apply_extra_yaml_keys(self.hass, user_input.get("extra_yaml_keys", []))
|
||||||
return self.async_create_entry(title="", data={})
|
return self.async_create_entry(title="", data={})
|
||||||
|
|
||||||
|
|
||||||
@@ -434,21 +700,30 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
|||||||
# The sidebar-panel sentence in the description is only truthful while
|
# The sidebar-panel sentence in the description is only truthful while
|
||||||
# the panel is registered; drop it (from the CURRENT stored options, not
|
# the panel is registered; drop it (from the CURRENT stored options, not
|
||||||
# the unsaved form state) when the panel is off so the link cannot point
|
# the unsaved form state) when the panel is off so the link cannot point
|
||||||
# at a route that 404s. The trailing space keeps the surrounding prose
|
# at a route that 404s. The separator keeps the surrounding prose spaced
|
||||||
# spaced correctly whether the sentence is present or empty.
|
# correctly whether the sentence is present or empty.
|
||||||
|
common, language = await _common_strings(getattr(self, "hass", None))
|
||||||
panel_hint = (
|
panel_hint = (
|
||||||
"Open the [HA-MCP settings panel](/ha-mcp) for tool management and "
|
_sentence_prefix(
|
||||||
"server settings. "
|
common["panel_hint"], language, _COMMON_FALLBACKS["panel_hint"]
|
||||||
|
)
|
||||||
if bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True))
|
if bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True))
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
# The tools-module status renders as its own paragraph directly under
|
||||||
|
# the version line, sharing the {versions} placeholder so every
|
||||||
|
# translation shows it without a strings change.
|
||||||
|
versions = await self._versions_hint(common)
|
||||||
|
tools_hint = self._tools_module_hint(common)
|
||||||
|
if tools_hint:
|
||||||
|
versions = f"{versions}\n\n{tools_hint}"
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=schema,
|
data_schema=schema,
|
||||||
description_placeholders={
|
description_placeholders={
|
||||||
"versions": await self._versions_hint(),
|
"versions": versions,
|
||||||
"connect_url": await self._connect_url_hint(),
|
"connect_url": await self._connect_url_hint(common),
|
||||||
"oauth_creds": self._oauth_creds_hint(),
|
"oauth_creds": self._oauth_creds_hint(common),
|
||||||
"llm_api_docs_url": LLM_API_DOCS_URL,
|
"llm_api_docs_url": LLM_API_DOCS_URL,
|
||||||
"panel_hint": panel_hint,
|
"panel_hint": panel_hint,
|
||||||
},
|
},
|
||||||
@@ -494,7 +769,7 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
|||||||
cleaned.pop(OPT_SERVER_URL, None)
|
cleaned.pop(OPT_SERVER_URL, None)
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
async def _versions_hint(self) -> str:
|
async def _versions_hint(self, common: dict[str, str]) -> str:
|
||||||
"""Return a one-line component + server version summary for the form.
|
"""Return a one-line component + server version summary for the form.
|
||||||
|
|
||||||
Reads the component version from the integration manifest and the
|
Reads the component version from the integration manifest and the
|
||||||
@@ -506,15 +781,28 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
|||||||
opts = self.config_entry.options
|
opts = self.config_entry.options
|
||||||
channel = str(opts.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
channel = str(opts.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
||||||
|
|
||||||
component_version = "unknown"
|
component_version = common["version_unknown"]
|
||||||
hass = getattr(self, "hass", None)
|
hass = getattr(self, "hass", None)
|
||||||
if hass is not None:
|
if hass is not None:
|
||||||
try:
|
try:
|
||||||
integration = await async_get_integration(hass, DOMAIN)
|
integration = await async_get_integration(hass, DOMAIN)
|
||||||
|
# A manifest without a version yields None, not an exception —
|
||||||
|
# ``str()`` would render the literal "None" into the form and
|
||||||
|
# the "unknown" wording would never appear for the likeliest
|
||||||
|
# defect it exists for.
|
||||||
|
if integration.version is not None:
|
||||||
component_version = str(integration.version)
|
component_version = str(integration.version)
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"The %s manifest carries no version; the options form "
|
||||||
|
"shows the unknown-version wording",
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.debug(
|
_LOGGER.warning(
|
||||||
"Could not read component version for the options hint: %s", err
|
"Could not read the component version for the options hint, "
|
||||||
|
"showing the unknown-version wording: %s",
|
||||||
|
err,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -525,31 +813,80 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
|||||||
if hass is not None
|
if hass is not None
|
||||||
else _installed_server_version()
|
else _installed_server_version()
|
||||||
)
|
)
|
||||||
server_version = raw_version or "not installed yet"
|
server_version = raw_version or common["version_not_installed"]
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.debug("Could not read server version for the options hint: %s", err)
|
_LOGGER.warning(
|
||||||
server_version = "not installed yet"
|
"Could not read the server version for the options hint, showing "
|
||||||
|
"the not-installed wording: %s",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
server_version = common["version_not_installed"]
|
||||||
|
|
||||||
return (
|
return _fill(
|
||||||
f"Component {component_version} - "
|
common,
|
||||||
f"Server ha-mcp {server_version} ({channel} channel)"
|
"version_line",
|
||||||
|
component_version=component_version,
|
||||||
|
server_version=server_version,
|
||||||
|
channel=channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _connect_url_hint(self) -> str:
|
def _tools_module_hint(self, common: dict[str, str]) -> str | None:
|
||||||
|
"""Return the File & YAML tools entry status line, or None if unreadable.
|
||||||
|
|
||||||
|
Shown directly under the version line (#1996): users routinely add the
|
||||||
|
server entry only and never learn the file / YAML tools need the second
|
||||||
|
"HA-MCP File & YAML Tools" entry until a tool call fails. An entry
|
||||||
|
that exists but is not loaded (disabled, or setup failed) serves no
|
||||||
|
services either, so it reports "not loaded" rather than Installed.
|
||||||
|
Failure-proof like the other hints: any read error drops the line
|
||||||
|
rather than breaking the options form.
|
||||||
|
"""
|
||||||
|
hass = getattr(self, "hass", None)
|
||||||
|
if hass is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# A missing entry_type means tools (pre-#1527 entries never carried
|
||||||
|
# the discriminator) — same default async_setup_entry dispatches on.
|
||||||
|
tools_entries = [
|
||||||
|
entry
|
||||||
|
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||||
|
if entry.data.get(CONF_ENTRY_TYPE, ENTRY_TYPE_TOOLS) == ENTRY_TYPE_TOOLS
|
||||||
|
]
|
||||||
|
loaded = any(
|
||||||
|
entry.state is ConfigEntryState.LOADED for entry in tools_entries
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
# Warning, like the two version reads above: this drops the whole
|
||||||
|
# tools-module paragraph from the form, which is a larger visible
|
||||||
|
# loss than either of those fallbacks.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not read the tools-entry state, dropping the "
|
||||||
|
"tools-module line from the options form: %s",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if loaded:
|
||||||
|
return common["tools_module_installed"]
|
||||||
|
if tools_entries:
|
||||||
|
return common["tools_module_not_loaded"]
|
||||||
|
return common["tools_module_not_installed"]
|
||||||
|
|
||||||
|
async def _connect_url_hint(self, common: dict[str, str]) -> str:
|
||||||
"""Return the connect URLs for the options form.
|
"""Return the connect URLs for the options form.
|
||||||
|
|
||||||
The Configure screen is admin-only, so it shows the real resolved
|
The Configure screen is admin-only, so it shows the real resolved
|
||||||
URLs (the start-up notification deliberately does not - it is visible
|
URLs (the start-up notification deliberately does not - it is visible
|
||||||
to every signed-in user). Falls back to a placeholder form when
|
to every signed-in user). Falls back to a placeholder form when
|
||||||
resolution is unavailable.
|
resolution is unavailable.
|
||||||
|
|
||||||
|
The URLs themselves and the two ``<...>`` stand-ins stay verbatim: they
|
||||||
|
are addresses to copy, not prose. Everything around them comes from the
|
||||||
|
``common`` catalog.
|
||||||
"""
|
"""
|
||||||
webhook_id = self.config_entry.data.get(DATA_WEBHOOK_ID)
|
webhook_id = self.config_entry.data.get(DATA_WEBHOOK_ID)
|
||||||
secret_path = self.config_entry.data.get(DATA_SECRET_PATH)
|
secret_path = self.config_entry.data.get(DATA_SECRET_PATH)
|
||||||
if not webhook_id:
|
if not webhook_id:
|
||||||
return (
|
return common["connect_urls_pending"]
|
||||||
"The connect URLs appear here (and in the Home Assistant log) "
|
|
||||||
"once the server has started."
|
|
||||||
)
|
|
||||||
webhook_enabled = bool(self.config_entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
webhook_enabled = bool(self.config_entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
||||||
port = self.config_entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT)
|
port = self.config_entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT)
|
||||||
hass = getattr(self, "hass", None)
|
hass = getattr(self, "hass", None)
|
||||||
@@ -564,7 +901,8 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
|||||||
extra_hosts=await async_get_lan_hosts(hass),
|
extra_hosts=await async_get_lan_hosts(hass),
|
||||||
)
|
)
|
||||||
if urls:
|
if urls:
|
||||||
return "Connect URL(s):\n" + "\n".join(f"- {u}" for u in urls)
|
listed = "\n".join(f"- {u}" for u in urls)
|
||||||
|
return f"{common['connect_urls_label']}\n{listed}"
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
# The hint is auxiliary display data: a resolution bug must not
|
# The hint is auxiliary display data: a resolution bug must not
|
||||||
# take down the whole options form, but the degradation should
|
# take down the whole options form, but the degradation should
|
||||||
@@ -576,26 +914,32 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
|||||||
# Local-only mode: the webhook endpoint is never registered, so
|
# Local-only mode: the webhook endpoint is never registered, so
|
||||||
# a webhook URL here would 404. With loopback binding the builder
|
# a webhook URL here would 404. With loopback binding the builder
|
||||||
# resolves no URLs at all - state that instead of inventing one.
|
# resolves no URLs at all - state that instead of inventing one.
|
||||||
hint = "Remote access via webhook is disabled (local-only mode)."
|
hint = common["connect_webhook_disabled"]
|
||||||
if secret_path:
|
if secret_path:
|
||||||
hint += (
|
direct = _fill(
|
||||||
f"\nDirect access from the Home Assistant machine: "
|
common,
|
||||||
f"http://127.0.0.1:{port}{secret_path}"
|
"connect_direct_access",
|
||||||
|
url=f"http://127.0.0.1:{port}{secret_path}",
|
||||||
)
|
)
|
||||||
|
hint += f"\n{direct}"
|
||||||
return hint
|
return hint
|
||||||
external = str(self.config_entry.options.get(OPT_EXTERNAL_URL) or "").rstrip(
|
external = str(self.config_entry.options.get(OPT_EXTERNAL_URL) or "").rstrip(
|
||||||
"/"
|
"/"
|
||||||
)
|
)
|
||||||
base = external or "<your-home-assistant-url>"
|
base = external or "<your-home-assistant-url>"
|
||||||
hint = f"Remote connect URL: {base}/api/webhook/{webhook_id}"
|
hint = _fill(
|
||||||
if secret_path:
|
common, "connect_remote_url", url=f"{base}/api/webhook/{webhook_id}"
|
||||||
hint += (
|
|
||||||
f"\nLocal/LAN (when bind host is 0.0.0.0): "
|
|
||||||
f"http://<home-assistant-ip>:{port}{secret_path}"
|
|
||||||
)
|
)
|
||||||
|
if secret_path:
|
||||||
|
lan = _fill(
|
||||||
|
common,
|
||||||
|
"connect_local_lan",
|
||||||
|
url=f"http://<home-assistant-ip>:{port}{secret_path}",
|
||||||
|
)
|
||||||
|
hint += f"\n{lan}"
|
||||||
return hint
|
return hint
|
||||||
|
|
||||||
def _oauth_creds_hint(self) -> str:
|
def _oauth_creds_hint(self, common: dict[str, str]) -> str:
|
||||||
"""Return the resolved legacy OAuth Client ID + Secret for the options
|
"""Return the resolved legacy OAuth Client ID + Secret for the options
|
||||||
form, or a note pointing at the mode selector when legacy mode isn't
|
form, or a note pointing at the mode selector when legacy mode isn't
|
||||||
the CONFIGURED mode. Admin-only screen (like ``_connect_url_hint``),
|
the CONFIGURED mode. Admin-only screen (like ``_connect_url_hint``),
|
||||||
@@ -605,23 +949,21 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
|||||||
log, where a still-valid old-identity token could read them; an HA
|
log, where a still-valid old-identity token could read them; an HA
|
||||||
admin here is trusted). A pending rotation gets a caveat so the admin
|
admin here is trusted). A pending rotation gets a caveat so the admin
|
||||||
doesn't paste values that only start working after the restart.
|
doesn't paste values that only start working after the restart.
|
||||||
|
|
||||||
|
The ``Client ID`` / ``Client Secret`` labels stay verbatim: they name
|
||||||
|
the two fields the client an admin pastes them into asks for, and those
|
||||||
|
clients label them in English whatever this instance's language is.
|
||||||
"""
|
"""
|
||||||
configured_mode = str(self.config_entry.options.get(OPT_WEBHOOK_AUTH) or "")
|
configured_mode = str(self.config_entry.options.get(OPT_WEBHOOK_AUTH) or "")
|
||||||
if configured_mode != WEBHOOK_AUTH_LEGACY:
|
if configured_mode != WEBHOOK_AUTH_LEGACY:
|
||||||
return (
|
return common["oauth_select_legacy_mode"]
|
||||||
"Set Authentication mode to legacy OAuth above and save to "
|
|
||||||
"generate a Client ID and Client Secret."
|
|
||||||
)
|
|
||||||
client_id = self.config_entry.data.get(DATA_OAUTH_CLIENT_ID)
|
client_id = self.config_entry.data.get(DATA_OAUTH_CLIENT_ID)
|
||||||
client_secret = self.config_entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
client_secret = self.config_entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
||||||
if not client_id or not client_secret:
|
if not client_id or not client_secret:
|
||||||
# Not minted yet — the entry hasn't finished a bring-up cycle
|
# Not minted yet — the entry hasn't finished a bring-up cycle
|
||||||
# since legacy mode was selected (e.g. this save just turned it
|
# since legacy mode was selected (e.g. this save just turned it
|
||||||
# on). They appear after the next reload.
|
# on). They appear after the next reload.
|
||||||
return (
|
return common["oauth_creds_pending"]
|
||||||
"The Client ID and Client Secret appear here once the server "
|
|
||||||
"has started."
|
|
||||||
)
|
|
||||||
creds = f"Client ID: {client_id}\nClient Secret: {client_secret}"
|
creds = f"Client ID: {client_id}\nClient Secret: {client_secret}"
|
||||||
signing_key = str(self.config_entry.data.get(DATA_OAUTH_SIGNING_KEY) or "")
|
signing_key = str(self.config_entry.data.get(DATA_OAUTH_SIGNING_KEY) or "")
|
||||||
active = _legacy_credentials_active(
|
active = _legacy_credentials_active(
|
||||||
@@ -636,8 +978,4 @@ class HaMcpServerOptionsFlow(OptionsFlow):
|
|||||||
# previous Client ID and Client Secret remain active" was false at a
|
# previous Client ID and Client Secret remain active" was false at a
|
||||||
# first enable and when nothing of ours is bound. Matches the startup
|
# first enable and when nothing of ours is bound. Matches the startup
|
||||||
# log's first-enable caveat and the oauth_regenerate help text.
|
# log's first-enable caveat and the oauth_regenerate help text.
|
||||||
return (
|
return f"{creds}\n{common['oauth_not_serving']}"
|
||||||
f"{creds}\n"
|
|
||||||
"Legacy OAuth is not serving these yet — restart Home Assistant "
|
|
||||||
"when it asks you to, to activate them."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ DOMAIN = "ha_mcp_tools"
|
|||||||
|
|
||||||
# Component version, kept in lockstep with ``manifest.json``'s ``version``.
|
# Component version, kept in lockstep with ``manifest.json``'s ``version``.
|
||||||
# ``ha_mcp_tools/info`` reports this so the server can display/debug the running
|
# ``ha_mcp_tools/info`` reports this so the server can display/debug the running
|
||||||
# component build; ``TestManifestVersionParity`` pins the two together so a
|
# component build; ``TestInfo::test_manifest_version_parity`` pins the two
|
||||||
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
|
# together so a manifest bump that forgets this constant (or vice-versa) fails
|
||||||
|
# in CI. The
|
||||||
# capability negotiation — not this version — gates each WS command (see
|
# capability negotiation — not this version — gates each WS command (see
|
||||||
# ``websocket_api.CAPABILITIES``).
|
# ``websocket_api.CAPABILITIES``).
|
||||||
COMPONENT_VERSION = "1.2.3"
|
COMPONENT_VERSION = "1.3.2"
|
||||||
|
|
||||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||||
# means "tools" so the pre-existing services entry keeps working across the
|
# means "tools" so the pre-existing services entry keeps working across the
|
||||||
@@ -96,7 +97,11 @@ ALLOWED_YAML_CONFIG_FILES = ["configuration.yaml"]
|
|||||||
|
|
||||||
# Top-level YAML keys allowed for editing in any allowed file
|
# Top-level YAML keys allowed for editing in any allowed file
|
||||||
# (configuration.yaml or packages/*.yaml).
|
# (configuration.yaml or packages/*.yaml).
|
||||||
# ONLY keys that have no UI/API alternative belong here.
|
# The bar is "YAML is a legitimate way to manage this key", not "this key
|
||||||
|
# has no UI alternative": template, utility_meter and group do have helper
|
||||||
|
# equivalents and stay allowed for git-managed YAML configs (the caller
|
||||||
|
# attaches a routing warning instead – see _HELPER_EQUIVALENT_KEYS in
|
||||||
|
# src/ha_mcp/tools/tools_yaml_config.py).
|
||||||
# Keys manageable via ha_config_set_helper (input_*, counter, timer, schedule)
|
# Keys manageable via ha_config_set_helper (input_*, counter, timer, schedule)
|
||||||
# are intentionally excluded. automation/script/scene live in
|
# are intentionally excluded. automation/script/scene live in
|
||||||
# PACKAGES_ONLY_YAML_KEYS below — they have storage-mode equivalents
|
# PACKAGES_ONLY_YAML_KEYS below — they have storage-mode equivalents
|
||||||
@@ -143,6 +148,51 @@ PACKAGES_ONLY_YAML_KEYS = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Top-level YAML keys an operator can never unlock (#1887).
|
||||||
|
# The operator-configurable extra-key list (ha-mcp's "extra YAML write
|
||||||
|
# keys" setting) is additive on top of ALLOWED_YAML_KEYS, so this floor
|
||||||
|
# is what keeps that setting from reaching HA's own trust boundary. It is
|
||||||
|
# checked before every single-key allowlist branch and is deliberately NOT
|
||||||
|
# operator-extendable – otherwise the same trust question just reopens
|
||||||
|
# one level up. Scope note: it guards the per-key merge path only.
|
||||||
|
# ``action="replace_file"`` returns before key validation runs at all, so a
|
||||||
|
# whole-file rewrite of configuration.yaml can still contain these keys –
|
||||||
|
# pre-existing behaviour, and the reason this is a floor under the extra-key
|
||||||
|
# setting rather than a general "these keys are unwritable" guarantee.
|
||||||
|
#
|
||||||
|
# The bar is not "powerful": command_line, shell_command and rest are
|
||||||
|
# already allowed above, so command execution and outbound HTTP are
|
||||||
|
# accepted surface. The bar is "redefines authentication, escalates the
|
||||||
|
# write surface itself, or can lock the user out" – unrecoverable in a
|
||||||
|
# way a broken sensor is not. Verified against home-assistant/core:
|
||||||
|
# homeassistant: CORE_CONFIG_SCHEMA (homeassistant/core_config.py) takes
|
||||||
|
# auth_providers / auth_mfa_modules (how the instance authenticates)
|
||||||
|
# and packages (which folder is loaded as packages – a write here
|
||||||
|
# would redirect the very surface this feature is bounded by).
|
||||||
|
# http: takes trusted_proxies + use_x_forwarded_for (a spoofable
|
||||||
|
# X-Forwarded-For becomes an auth bypass), cors_allowed_origins, and
|
||||||
|
# ip_ban_enabled / login_attempts_threshold (brute-force protection).
|
||||||
|
# frontend: takes extra_module_url, JavaScript modules loaded into the
|
||||||
|
# authenticated dashboard – a stored-XSS foothold with access to the
|
||||||
|
# instance and its tokens.
|
||||||
|
# lovelace: takes resources (url + type: module), loaded whenever
|
||||||
|
# resource_mode resolves to yaml. That is the same JS-into-an-
|
||||||
|
# authenticated-dashboard primitive as frontend: extra_module_url, so
|
||||||
|
# denying one while allowing the other would be a floor contradicting
|
||||||
|
# its own rationale. Only the bare key is denied; the validated
|
||||||
|
# lovelace.dashboards.<url_path> shape is a different branch and stays
|
||||||
|
# available for YAML-mode dashboard management.
|
||||||
|
# auth and api are absent on purpose: both have an empty CONFIG_SCHEMA in
|
||||||
|
# core, so there is no sub-key to restrict.
|
||||||
|
YAML_KEY_DENYLIST = frozenset(
|
||||||
|
{
|
||||||
|
"homeassistant",
|
||||||
|
"http",
|
||||||
|
"frontend",
|
||||||
|
"lovelace",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Post-edit action required for each YAML key.
|
# Post-edit action required for each YAML key.
|
||||||
# template, mqtt, group, automation, script, and scene have first-party
|
# template, mqtt, group, automation, script, and scene have first-party
|
||||||
# reload services in HA core. All others require a full HA restart.
|
# reload services in HA core. All others require a full HA restart.
|
||||||
|
|||||||
@@ -32,13 +32,15 @@ import importlib.metadata
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import site
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||||
from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||||
@@ -49,7 +51,7 @@ from homeassistant.requirements import (
|
|||||||
async_process_requirements,
|
async_process_requirements,
|
||||||
pip_kwargs,
|
pip_kwargs,
|
||||||
)
|
)
|
||||||
from homeassistant.util.package import install_package
|
from homeassistant.util.package import is_virtual_env
|
||||||
from packaging.requirements import InvalidRequirement, Requirement
|
from packaging.requirements import InvalidRequirement, Requirement
|
||||||
from packaging.utils import canonicalize_name
|
from packaging.utils import canonicalize_name
|
||||||
from packaging.version import InvalidVersion, Version
|
from packaging.version import InvalidVersion, Version
|
||||||
@@ -116,6 +118,16 @@ _READY_POLL_INTERVAL_SECONDS = 0.5
|
|||||||
# leaking it rather than blocking HA shutdown.
|
# leaking it rather than blocking HA shutdown.
|
||||||
_STOP_JOIN_TIMEOUT_SECONDS = 10.0
|
_STOP_JOIN_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
|
# Budget for each teardown phase (the _serve resource cleanup and the
|
||||||
|
# worker-loop pending-task sweep). Mirrors the CLI runner's
|
||||||
|
# SHUTDOWN_TIMEOUT_SECONDS: both phases together must finish inside
|
||||||
|
# _STOP_JOIN_TIMEOUT_SECONDS, or async_stop declares the worker orphaned
|
||||||
|
# while the old thread is still executing shared ha_mcp modules. The budget
|
||||||
|
# bounds only the phases that accept one — asyncgen finalization and
|
||||||
|
# uvicorn's post-drain lifespan shutdown remain unbounded — so it buys
|
||||||
|
# headroom, not a hard ceiling on the join.
|
||||||
|
_TEARDOWN_TIMEOUT_SECONDS = 2.0
|
||||||
|
|
||||||
# Per-download HTTP timeout for a forced reinstall. The first install pulls the
|
# Per-download HTTP timeout for a forced reinstall. The first install pulls the
|
||||||
# whole fastmcp tree, well beyond HA's 60s requirements default.
|
# whole fastmcp tree, well beyond HA's 60s requirements default.
|
||||||
_PIP_INSTALL_TIMEOUT_SECONDS = 300
|
_PIP_INSTALL_TIMEOUT_SECONDS = 300
|
||||||
@@ -123,6 +135,11 @@ _PIP_INSTALL_TIMEOUT_SECONDS = 300
|
|||||||
# Uninstall just removes files/metadata, so it is quick; cap it so a wedged
|
# Uninstall just removes files/metadata, so it is quick; cap it so a wedged
|
||||||
# subprocess can never tie up an executor thread indefinitely.
|
# subprocess can never tie up an executor thread indefinitely.
|
||||||
_PIP_UNINSTALL_TIMEOUT_SECONDS = 120
|
_PIP_UNINSTALL_TIMEOUT_SECONDS = 120
|
||||||
|
# Upper bound for ONE uv install attempt. Generous (a cold ARM wheel build
|
||||||
|
# is slow but finite) and bounded, so a wedged uv cannot hold the
|
||||||
|
# process-wide tracked-install slot — and with it the next bring-up —
|
||||||
|
# forever. The extra-index fallback can spend this twice.
|
||||||
|
_UV_INSTALL_TIMEOUT_SECONDS = 1800
|
||||||
|
|
||||||
# How long a bring-up waits for an install job orphaned by a CANCELLED
|
# How long a bring-up waits for an install job orphaned by a CANCELLED
|
||||||
# previous bring-up before giving up: asyncio cancellation detaches the
|
# previous bring-up before giving up: asyncio cancellation detaches the
|
||||||
@@ -641,23 +658,28 @@ class EmbeddedServerManager:
|
|||||||
With auto-update on (the default) both channels install their
|
With auto-update on (the default) both channels install their
|
||||||
distribution UNPINNED, so every entry reload / HA restart must pick up
|
distribution UNPINNED, so every entry reload / HA restart must pick up
|
||||||
the newest build. Such a spec ALWAYS takes the force-install path
|
the newest build. Such a spec ALWAYS takes the force-install path
|
||||||
(``upgrade=True``, bypassing the requirements manager's is-installed
|
(``--upgrade-package <dist>``, bypassing the requirements manager's
|
||||||
shortcut) — that is what makes the channel auto-update. This runs in a
|
is-installed shortcut) — that is what makes the channel auto-update,
|
||||||
|
and scoping the upgrade to our own distribution is what keeps it
|
||||||
|
from replacing packages Home Assistant ships (#2135/#2146). This runs in a
|
||||||
background task, so it never blocks HA startup, and uv no-ops quickly
|
background task, so it never blocks HA startup, and uv no-ops quickly
|
||||||
when the newest build is already installed.
|
when the newest build is already installed.
|
||||||
|
|
||||||
Fast path: reserved for a STABLE spec — an explicit pip-spec override (a
|
Fast path: reserved for a stable INDEX spec — an explicit pip-spec
|
||||||
version pin or tarball URL) or a channel with auto-update turned OFF
|
override that is a version pin, or a channel with auto-update turned OFF
|
||||||
(which pins to the installed version, see :meth:`_resolve_pip_spec`).
|
(which pins to the installed version, see :meth:`_resolve_pip_spec`).
|
||||||
When that spec matches the one last installed and the package imports,
|
When that spec matches the one last installed and the package imports,
|
||||||
delegate the "already satisfied?" decision to Home Assistant's
|
delegate the "already satisfied?" decision to Home Assistant's
|
||||||
requirements manager; a pinned spec does not move, so there is nothing to
|
requirements manager; a pinned spec does not move, so there is nothing to
|
||||||
upgrade to. A CHANGED spec (a new override, a cleared override, a
|
upgrade to. A URL override (a tarball or ``file://`` wheel) is
|
||||||
|
deliberately EXCLUDED: HA's is-installed check cannot verify a URL
|
||||||
|
requirement, so delegating one always reaches its bare ``--upgrade``
|
||||||
|
install — see the comment on ``spec_is_stable`` below. A CHANGED spec (a new override, a cleared override, a
|
||||||
toggled auto-update, a channel switch) falls through to the
|
toggled auto-update, a channel switch) falls through to the
|
||||||
force-install path below — and additionally uninstalls the replaced
|
force-install path below — and additionally uninstalls the replaced
|
||||||
distribution first (:meth:`_async_remove_replaced_source`), because
|
distribution first (:meth:`_async_remove_replaced_source`), because
|
||||||
``upgrade=True`` alone decides by version and a changed SOURCE can keep
|
the upgrade flag alone decides by version and a changed SOURCE can
|
||||||
the version string (issue #1914).
|
keep the version string (issue #1914).
|
||||||
|
|
||||||
On a channel switch the other channel's distribution is uninstalled first
|
On a channel switch the other channel's distribution is uninstalled first
|
||||||
(:meth:`_async_remove_conflicting_dist`): ``ha-mcp`` and ``ha-mcp-dev``
|
(:meth:`_async_remove_conflicting_dist`): ``ha-mcp`` and ``ha-mcp-dev``
|
||||||
@@ -727,7 +749,20 @@ class EmbeddedServerManager:
|
|||||||
# A "stable" spec (an explicit override, or a channel pinned because
|
# A "stable" spec (an explicit override, or a channel pinned because
|
||||||
# auto-update is off) is eligible for the fast path; an unpinned
|
# auto-update is off) is eligible for the fast path; an unpinned
|
||||||
# auto-updating channel never is.
|
# auto-updating channel never is.
|
||||||
spec_is_stable = bool(self._pip_spec_override) or not self._auto_update
|
# A URL spec is never eligible, however stable it looks. The fast
|
||||||
|
# path delegates to HA's requirements manager, and
|
||||||
|
# homeassistant.util.package.is_installed() returns False for ANY
|
||||||
|
# requirement carrying a URL ("we cannot verify versions, so let the
|
||||||
|
# package manager handle it"), so async_process_requirements always
|
||||||
|
# reaches install_package(), whose upgrade default appends a bare
|
||||||
|
# --upgrade. That re-resolves the whole graph and replaces packages
|
||||||
|
# HA only floors — the #2135/#2146 tear, on every restart. Routing
|
||||||
|
# URL specs to the force path costs a scoped --reinstall-package of
|
||||||
|
# OUR distribution only, which is the install HA would have done
|
||||||
|
# anyway, minus the stomp.
|
||||||
|
spec_is_stable = (
|
||||||
|
bool(self._pip_spec_override) or not self._auto_update
|
||||||
|
) and not _spec_is_url_requirement(self._pip_spec)
|
||||||
fast_path_ok = (
|
fast_path_ok = (
|
||||||
spec_is_stable
|
spec_is_stable
|
||||||
and stored_spec == self._pip_spec
|
and stored_spec == self._pip_spec
|
||||||
@@ -763,9 +798,10 @@ class EmbeddedServerManager:
|
|||||||
raise EmbeddedServerError(
|
raise EmbeddedServerError(
|
||||||
f"The installer left installed ha-mcp {version}, but this "
|
f"The installer left installed ha-mcp {version}, but this "
|
||||||
f"in-process component requires {MIN_EMBEDDED_SERVER_VERSION} "
|
f"in-process component requires {MIN_EMBEDDED_SERVER_VERSION} "
|
||||||
"or newer. Review resolver details logged under "
|
"or newer. Review the installer output logged under "
|
||||||
"homeassistant.util.package, correct the package conflict, and "
|
"custom_components.ha_mcp_tools.embedded_server (or, for an "
|
||||||
"reload this integration.",
|
"index spec taking the fast path, homeassistant.util.package), "
|
||||||
|
"correct the package conflict, and reload this integration.",
|
||||||
kind="package",
|
kind="package",
|
||||||
)
|
)
|
||||||
_LOGGER.info("HA-MCP in-process server package ready (version %s)", version)
|
_LOGGER.info("HA-MCP in-process server package ready (version %s)", version)
|
||||||
@@ -855,7 +891,8 @@ class EmbeddedServerManager:
|
|||||||
|
|
||||||
Returns None for an override that names an unknown distribution or
|
Returns None for an override that names an unknown distribution or
|
||||||
does not parse as a requirement at all (a direct URL): the installer
|
does not parse as a requirement at all (a direct URL): the installer
|
||||||
re-fetches and rebuilds URL requirements under ``upgrade=True``
|
reinstalls a named URL requirement outright
|
||||||
|
(``--reinstall-package``, see :func:`_force_install_package`)
|
||||||
regardless of the installed version, so a URL install is already
|
regardless of the installed version, so a URL install is already
|
||||||
real and nothing needs removing.
|
real and nothing needs removing.
|
||||||
"""
|
"""
|
||||||
@@ -875,7 +912,7 @@ class EmbeddedServerManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Uninstall the replaced distribution when the requested source changed.
|
"""Uninstall the replaced distribution when the requested source changed.
|
||||||
|
|
||||||
The forced install that follows relies on ``upgrade=True``, and the
|
The forced install that follows relies on its upgrade flag, and the
|
||||||
installer decides "already satisfied" by VERSION alone — but a source
|
installer decides "already satisfied" by VERSION alone — but a source
|
||||||
change can keep the version string. A PR branch's committed
|
change can keep the version string. A PR branch's committed
|
||||||
``project.version`` equals the release it branched from (only release
|
``project.version`` equals the release it branched from (only release
|
||||||
@@ -893,7 +930,7 @@ class EmbeddedServerManager:
|
|||||||
Skipped when nothing is installed, when the last-installed spec is
|
Skipped when nothing is installed, when the last-installed spec is
|
||||||
unknown (nothing to compare: first install, or entry data predating
|
unknown (nothing to compare: first install, or entry data predating
|
||||||
the spec tracking), when the spec is unchanged (the routine
|
the spec tracking), when the spec is unchanged (the routine
|
||||||
reload/restart path, where ``upgrade=True`` alone is correct and an
|
reload/restart path, where the upgrade flag alone is correct and an
|
||||||
uninstall would churn — and briefly break — a healthy install on
|
uninstall would churn — and briefly break — a healthy install on
|
||||||
every restart), when the new spec is a direct URL (always installs
|
every restart), when the new spec is a direct URL (always installs
|
||||||
for real), when the named distribution is not installed (e.g. a
|
for real), when the named distribution is not installed (e.g. a
|
||||||
@@ -918,6 +955,19 @@ class EmbeddedServerManager:
|
|||||||
return
|
return
|
||||||
if stored_spec == self._pip_spec:
|
if stored_spec == self._pip_spec:
|
||||||
return
|
return
|
||||||
|
if _spec_is_url_requirement(self._pip_spec):
|
||||||
|
# A URL spec is reinstalled outright (--reinstall-package, see
|
||||||
|
# _scoped_install_flags), so the install cannot be skipped as
|
||||||
|
# "already satisfied" and there is nothing for this uninstall to
|
||||||
|
# unblock. Removing first would only delete the working build
|
||||||
|
# BEFORE the new URL is fetched, so a failed fetch (bad path,
|
||||||
|
# network, moved tarball) leaves no server installed at all —
|
||||||
|
# and it reopens the uninstall-then-extract window on our own
|
||||||
|
# package. _replaced_dist_name() already declines for a BARE
|
||||||
|
# url; a NAMED one ("ha-mcp @ file:///…", the shape the config
|
||||||
|
# flow and the e2e lane use) parses fine and would fall through
|
||||||
|
# to the removal below without this.
|
||||||
|
return
|
||||||
replaced_dist = self._replaced_dist_name()
|
replaced_dist = self._replaced_dist_name()
|
||||||
if replaced_dist is None:
|
if replaced_dist is None:
|
||||||
return
|
return
|
||||||
@@ -926,20 +976,27 @@ class EmbeddedServerManager:
|
|||||||
# code on disk came from the index too, so "already satisfied by
|
# code on disk came from the index too, so "already satisfied by
|
||||||
# version" is the truth, not the #1914 lie.
|
# version" is the truth, not the #1914 lie.
|
||||||
return
|
return
|
||||||
pinned = _exact_pinned_version(self._pip_spec)
|
|
||||||
if pinned is not None:
|
|
||||||
try:
|
|
||||||
version_moves = Version(pinned) != Version(installed_version)
|
|
||||||
except InvalidVersion:
|
|
||||||
version_moves = False # unprovable — keep the uninstall
|
|
||||||
if version_moves:
|
|
||||||
# The new pin cannot be satisfied by the installed version, so
|
|
||||||
# the forced install is guaranteed to be real without any
|
|
||||||
# uninstall — and keeping the working build in place preserves
|
|
||||||
# it as the fallback if that install fails (e.g. offline).
|
|
||||||
return
|
|
||||||
if not await self._hass.async_add_executor_job(_dist_installed, replaced_dist):
|
if not await self._hass.async_add_executor_job(_dist_installed, replaced_dist):
|
||||||
return
|
return
|
||||||
|
# Compare the pin against the version of the distribution actually
|
||||||
|
# being replaced, not the caller's ``installed_version``: that one is
|
||||||
|
# read from whichever dist provides ``ha_mcp`` and is read BEFORE
|
||||||
|
# _async_remove_conflicting_dist() runs, so on a cross-channel switch
|
||||||
|
# it can describe the other channel's dist — or one already
|
||||||
|
# uninstalled. Comparing against it could report "the pin moved" for a
|
||||||
|
# target that is in fact already at the pinned version, skip this
|
||||||
|
# uninstall, and let the install no-op as satisfied (#1914).
|
||||||
|
replaced_version = await self._hass.async_add_executor_job(
|
||||||
|
_installed_dist_version, replaced_dist
|
||||||
|
)
|
||||||
|
if replaced_version is not None and _pin_moves_off_installed(
|
||||||
|
self._pip_spec, replaced_version
|
||||||
|
):
|
||||||
|
# The new pin cannot be satisfied by the installed version, so the
|
||||||
|
# forced install is guaranteed to be real without any uninstall —
|
||||||
|
# and keeping the working build in place preserves it as the
|
||||||
|
# fallback if that install fails (e.g. offline).
|
||||||
|
return
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"The requested server source changed (%r -> %r); removing the "
|
"The requested server source changed (%r -> %r); removing the "
|
||||||
"installed %r first so the reinstall cannot be skipped as "
|
"installed %r first so the reinstall cannot be skipped as "
|
||||||
@@ -982,23 +1039,36 @@ class EmbeddedServerManager:
|
|||||||
|
|
||||||
Mirrors how ``homeassistant.requirements`` builds its pip invocation
|
Mirrors how ``homeassistant.requirements`` builds its pip invocation
|
||||||
(HA's own constraints file + ``config/deps`` target where applicable) so
|
(HA's own constraints file + ``config/deps`` target where applicable) so
|
||||||
the resolver honors Home Assistant's constraints, then installs with
|
the resolver honors Home Assistant's constraints, with one deliberate
|
||||||
``upgrade=True`` and a generous per-download timeout.
|
difference from ``install_package(upgrade=True)``: that maps to uv's
|
||||||
|
EAGER ``--upgrade``, which re-resolves the whole dependency graph to
|
||||||
|
the newest allowed versions and replaces packages Home Assistant
|
||||||
|
already ships even when the installed version satisfies our spec —
|
||||||
|
exactly how the image's websockets kept getting force-replaced
|
||||||
|
(#2135/#2146). ``--upgrade-package`` scopes the upgrade to ha-mcp's
|
||||||
|
own distribution: the server still auto-updates, every other
|
||||||
|
installed package is kept whenever it satisfies the resolution.
|
||||||
"""
|
"""
|
||||||
kwargs = pip_kwargs(self._hass.config.config_dir)
|
kwargs = pip_kwargs(self._hass.config.config_dir)
|
||||||
kwargs["timeout"] = max(
|
timeout = max(int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS)
|
||||||
int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS
|
|
||||||
)
|
|
||||||
installed = await self._async_run_tracked_install_job(
|
installed = await self._async_run_tracked_install_job(
|
||||||
partial(install_package, self._pip_spec, upgrade=True, **kwargs)
|
partial(
|
||||||
|
_force_install_package,
|
||||||
|
self._pip_spec,
|
||||||
|
channel_dist=dist_for_channel(self._channel),
|
||||||
|
constraints=kwargs.get("constraints"),
|
||||||
|
target=kwargs.get("target"),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if not installed:
|
if not installed:
|
||||||
raise EmbeddedServerError(
|
raise EmbeddedServerError(
|
||||||
f"Could not install the server ({self._pip_spec!r}). The "
|
f"Could not install the server ({self._pip_spec!r}). The "
|
||||||
f"in-process server requires ha-mcp "
|
f"in-process server requires ha-mcp "
|
||||||
f"{MIN_EMBEDDED_SERVER_VERSION} or newer and Home Assistant "
|
f"{MIN_EMBEDDED_SERVER_VERSION} or newer and Home Assistant "
|
||||||
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION} or newer. Resolver "
|
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION} or newer. The "
|
||||||
"details are logged under homeassistant.util.package.",
|
"installer's output is logged under "
|
||||||
|
"custom_components.ha_mcp_tools.embedded_server.",
|
||||||
kind="package",
|
kind="package",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1201,24 +1271,7 @@ class EmbeddedServerManager:
|
|||||||
finally:
|
finally:
|
||||||
with _IMPORTING_WORKERS_LOCK:
|
with _IMPORTING_WORKERS_LOCK:
|
||||||
_IMPORTING_WORKERS.discard(threading.current_thread())
|
_IMPORTING_WORKERS.discard(threading.current_thread())
|
||||||
# Teardown is best-effort but never SILENT (review finding): a
|
_teardown_worker_loop(loop)
|
||||||
# raise here must not mask the primary outcome, yet a recurring
|
|
||||||
# cleanup failure (leaking executor threads across reloads) has
|
|
||||||
# to be visible in the logs. Each call gets its own guard so one
|
|
||||||
# failure cannot skip the other.
|
|
||||||
for _label, _coro_factory in (
|
|
||||||
("asyncgen", loop.shutdown_asyncgens),
|
|
||||||
("executor", loop.shutdown_default_executor),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
loop.run_until_complete(_coro_factory())
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Worker-loop %s shutdown failed during teardown",
|
|
||||||
_label,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
async def _serve(self, access_token: str, stop_event: asyncio.Event) -> None:
|
async def _serve(self, access_token: str, stop_event: asyncio.Event) -> None:
|
||||||
"""Build the ha-mcp server and run it until a stop is signaled.
|
"""Build the ha-mcp server and run it until a stop is signaled.
|
||||||
@@ -1387,7 +1440,16 @@ class EmbeddedServerManager:
|
|||||||
port=self._port,
|
port=self._port,
|
||||||
timeout_graceful_shutdown=2,
|
timeout_graceful_shutdown=2,
|
||||||
lifespan="on",
|
lifespan="on",
|
||||||
ws="websockets-sansio",
|
# HTTP-ONLY listener, so no WebSocket protocol is loaded. uvicorn
|
||||||
|
# resolves its ``ws`` class EAGERLY in Config.load(), and
|
||||||
|
# "websockets-sansio" imports the SHARED websockets package —
|
||||||
|
# the unowned, tearable copy ha-mcp vendors its own copy to stay
|
||||||
|
# clear of (#2135/#2146). With that setting a torn shared install
|
||||||
|
# crashed this server at listener startup no matter what the
|
||||||
|
# client imports. "none" resolves to None and imports nothing;
|
||||||
|
# the MCP app serves Streamable HTTP and registers no WebSocket
|
||||||
|
# route. Pinned by tests/src/unit/test_vendored_websockets.py.
|
||||||
|
ws="none",
|
||||||
# Leave Home Assistant's logging untouched — do not let uvicorn
|
# Leave Home Assistant's logging untouched — do not let uvicorn
|
||||||
# reconfigure the root logger from this thread.
|
# reconfigure the root logger from this thread.
|
||||||
log_config=None,
|
log_config=None,
|
||||||
@@ -1396,6 +1458,7 @@ class EmbeddedServerManager:
|
|||||||
|
|
||||||
self._note_startup_phase("starting the HTTP listener")
|
self._note_startup_phase("starting the HTTP listener")
|
||||||
stop_task = asyncio.create_task(stop_event.wait())
|
stop_task = asyncio.create_task(stop_event.wait())
|
||||||
|
try:
|
||||||
async with server.mcp._lifespan_manager():
|
async with server.mcp._lifespan_manager():
|
||||||
serve_task = asyncio.create_task(uv_server.serve())
|
serve_task = asyncio.create_task(uv_server.serve())
|
||||||
done, _pending = await asyncio.wait(
|
done, _pending = await asyncio.wait(
|
||||||
@@ -1404,15 +1467,24 @@ class EmbeddedServerManager:
|
|||||||
if stop_task in done:
|
if stop_task in done:
|
||||||
# Graceful shutdown through uvicorn's own path: waits out
|
# Graceful shutdown through uvicorn's own path: waits out
|
||||||
# in-flight requests (2s cap), runs lifespan shutdown, and
|
# in-flight requests (2s cap), runs lifespan shutdown, and
|
||||||
# deterministically releases the socket for the next bring-up.
|
# deterministically releases the socket for the next
|
||||||
|
# bring-up.
|
||||||
uv_server.should_exit = True
|
uv_server.should_exit = True
|
||||||
await serve_task
|
await serve_task
|
||||||
else:
|
else:
|
||||||
stop_task.cancel()
|
stop_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await stop_task
|
await stop_task
|
||||||
# Surface a server that exited on its own (bind failure, etc.).
|
# Surface a server that exited on its own (bind failure,
|
||||||
|
# etc.).
|
||||||
serve_task.result()
|
serve_task.result()
|
||||||
|
finally:
|
||||||
|
# CLI parity: the HTTP runner's shutdown path releases the served
|
||||||
|
# stack's HA connections; this in-process runner must too, on this
|
||||||
|
# loop, while it still runs — otherwise the reader tasks are only
|
||||||
|
# cancelled by the thread's loop teardown and their sockets are
|
||||||
|
# abandoned to garbage collection (issue #2127).
|
||||||
|
await _shutdown_server_resources_bounded(server)
|
||||||
|
|
||||||
def _progress_signature(self) -> tuple[int, str]:
|
def _progress_signature(self) -> tuple[int, str]:
|
||||||
"""Snapshot the observable startup progress of the worker thread.
|
"""Snapshot the observable startup progress of the worker thread.
|
||||||
@@ -1526,6 +1598,149 @@ _IMPORTING_WORKERS_LOCK = threading.Lock()
|
|||||||
_IMPORTING_WORKERS: set[threading.Thread] = set()
|
_IMPORTING_WORKERS: set[threading.Thread] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _shutdown_server_resources_bounded(server: Any) -> None:
|
||||||
|
"""Run :func:`_shutdown_server_resources` inside the teardown budget.
|
||||||
|
|
||||||
|
An unresponsive peer's close handshake (websockets' 10s default
|
||||||
|
close_timeout) must not eat the whole ``_STOP_JOIN_TIMEOUT_SECONDS`` join
|
||||||
|
budget. Cancel-and-abandon, not ``wait_for``: ``wait_for`` awaits the
|
||||||
|
cancelled coroutine before raising, and the cleanup stack swallows
|
||||||
|
``CancelledError`` at several layers (per-client in
|
||||||
|
``WebSocketManager.disconnect``, in ``client.disconnect``'s own
|
||||||
|
task-cancel guard), so a straggler must be left to the thread's loop
|
||||||
|
teardown sweep instead of being joined here.
|
||||||
|
"""
|
||||||
|
task = asyncio.ensure_future(_shutdown_server_resources(server))
|
||||||
|
_done, pending = await asyncio.wait({task}, timeout=_TEARDOWN_TIMEOUT_SECONDS)
|
||||||
|
if pending:
|
||||||
|
task.cancel()
|
||||||
|
_LOGGER.warning("Embedded resource cleanup timed out")
|
||||||
|
|
||||||
|
|
||||||
|
async def _shutdown_server_resources(server: Any) -> None:
|
||||||
|
"""Release the served stack's Home Assistant connections on its own loop.
|
||||||
|
|
||||||
|
Mirrors the CLI runner's ``_cleanup_resources`` (``ha_mcp.__main__``)
|
||||||
|
without importing it: stop the WebSocket listener service, disconnect the
|
||||||
|
pooled WebSocket clients, and close the server's HTTP client. Every step
|
||||||
|
guards independently — a failing step must not keep the next one from
|
||||||
|
running, and no failure here may mask the serve outcome.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ha_mcp.client.websocket_listener import stop_websocket_listener
|
||||||
|
|
||||||
|
await stop_websocket_listener()
|
||||||
|
except ImportError:
|
||||||
|
_LOGGER.debug("WebSocket listener module not available")
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.warning("WebSocket listener cleanup failed: %s", err)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ha_mcp.client.websocket_client import websocket_manager
|
||||||
|
|
||||||
|
await websocket_manager.disconnect()
|
||||||
|
except ImportError:
|
||||||
|
_LOGGER.debug("WebSocket manager module not available")
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.warning("WebSocket manager cleanup failed: %s", err)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await server.close()
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.warning("Server cleanup failed: %s", err)
|
||||||
|
|
||||||
|
|
||||||
|
def _cancel_pending_tasks(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
"""Cancel every task still pending on ``loop`` and wait them out.
|
||||||
|
|
||||||
|
Mirrors ``asyncio.runners._cancel_all_tasks`` — the step ``asyncio.run``
|
||||||
|
performs between the main coroutine returning and asyncgen finalization,
|
||||||
|
which this worker's hand-rolled loop lifecycle skipped (issue #2127).
|
||||||
|
Without it, tasks the served stack leaves behind — WebSocket reader tasks
|
||||||
|
parked in ``Connection.__aiter__``, sse_starlette's ``_shutdown_watcher``
|
||||||
|
poll (unreachable by its uvicorn signal hooks on a non-main thread) — are
|
||||||
|
still pending at teardown: ``shutdown_asyncgens()`` then acloses
|
||||||
|
generators mid-``__anext__`` (``RuntimeError: aclose(): asynchronous
|
||||||
|
generator is already running``) and ``loop.close()`` destroys the
|
||||||
|
survivors ("Task was destroyed but it is pending!"), one error pair per
|
||||||
|
entry reload.
|
||||||
|
|
||||||
|
Abandoning is inherently partial: a task that ignores cancellation past
|
||||||
|
the budget and still drives an async generator leaves that generator
|
||||||
|
running, and ``shutdown_asyncgens()`` then reports the same ``aclose()``
|
||||||
|
error this sweep exists to remove. The ignored-cancellation warning
|
||||||
|
below is the tell when that residual fires.
|
||||||
|
"""
|
||||||
|
pending = asyncio.all_tasks(loop)
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
# Bounded, unlike asyncio.runners: async_stop joins this worker for only
|
||||||
|
# _STOP_JOIN_TIMEOUT_SECONDS, so a task that ignores cancellation gets
|
||||||
|
# logged and abandoned rather than hanging the join (the CLI's
|
||||||
|
# _cancel_tasks does the same, issue #2027 precedent).
|
||||||
|
done, still_pending = loop.run_until_complete(
|
||||||
|
asyncio.wait(pending, timeout=_TEARDOWN_TIMEOUT_SECONDS)
|
||||||
|
)
|
||||||
|
if still_pending:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%d task(s) ignored cancellation during worker-loop teardown",
|
||||||
|
len(still_pending),
|
||||||
|
)
|
||||||
|
for task in done:
|
||||||
|
if task.cancelled():
|
||||||
|
continue
|
||||||
|
exc = task.exception()
|
||||||
|
if exc is not None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Task %r raised during worker-loop teardown: %r",
|
||||||
|
task.get_name(),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _teardown_worker_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
"""Drain and close the worker loop with ``asyncio.run`` teardown parity.
|
||||||
|
|
||||||
|
Teardown is best-effort but never SILENT (review finding): a raise here
|
||||||
|
must not mask the primary outcome, yet a recurring cleanup failure
|
||||||
|
(leaking executor threads across reloads) has to be visible in the logs.
|
||||||
|
Each step gets its own guard so one failure cannot skip the others.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_cancel_pending_tasks(loop)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Worker-loop task cancellation failed during teardown",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
for _label, _coro_factory in (
|
||||||
|
("asyncgen", loop.shutdown_asyncgens),
|
||||||
|
# The executor join is bounded too: a stuck executor thread must not
|
||||||
|
# keep the worker alive past the join deadline (abandoning it emits
|
||||||
|
# a RuntimeWarning instead of hanging). The runtime has accepted
|
||||||
|
# timeout= since Python 3.12; typeshed's AbstractEventLoop signature
|
||||||
|
# lags behind, hence the scoped ignore.
|
||||||
|
(
|
||||||
|
"executor",
|
||||||
|
partial(
|
||||||
|
loop.shutdown_default_executor,
|
||||||
|
timeout=_TEARDOWN_TIMEOUT_SECONDS, # type: ignore[call-arg]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(_coro_factory())
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Worker-loop %s shutdown failed during teardown",
|
||||||
|
_label,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
def _prune_and_check_importing_workers() -> bool:
|
def _prune_and_check_importing_workers() -> bool:
|
||||||
"""Drop dead workers from the registry; return True if any live one remains."""
|
"""Drop dead workers from the registry; return True if any live one remains."""
|
||||||
with _IMPORTING_WORKERS_LOCK:
|
with _IMPORTING_WORKERS_LOCK:
|
||||||
@@ -1773,6 +1988,242 @@ def _uninstall_distribution(dist_name: str, *, target: str | None = None) -> boo
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _force_install_package(
|
||||||
|
spec: str,
|
||||||
|
*,
|
||||||
|
channel_dist: str | None,
|
||||||
|
constraints: str | None,
|
||||||
|
target: str | None,
|
||||||
|
timeout: int | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Install ``spec``, touching ONLY our own distribution (blocking).
|
||||||
|
|
||||||
|
Mirrors ``homeassistant.util.package.install_package``'s uv invocation
|
||||||
|
(index strategy, constraints, target, the uv --user workaround, and the
|
||||||
|
HTTP_TIMEOUT env) but never its eager ``--upgrade``, which re-resolves
|
||||||
|
EVERY dependency to the newest allowed version and replaces packages the
|
||||||
|
Home Assistant image already ships (#2135/#2146). The replacement flag
|
||||||
|
is chosen per spec shape by :func:`_scoped_install_flags`;
|
||||||
|
``channel_dist`` is the distribution the active channel installs, used
|
||||||
|
to scope a bare URL that names none of its own.
|
||||||
|
"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
if timeout:
|
||||||
|
env["HTTP_TIMEOUT"] = str(timeout)
|
||||||
|
args = _uv_install_args(
|
||||||
|
spec,
|
||||||
|
channel_dist=channel_dist,
|
||||||
|
constraints=constraints,
|
||||||
|
target=target,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
_LOGGER.info("Installing the in-process server package: %s", spec)
|
||||||
|
stderr = _run_uv_install(args, env)
|
||||||
|
if stderr is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# install_package's extra-index fallback, mirrored: uv treats a failing
|
||||||
|
# extra index as FATAL where pip merely skips it, so a wheels-index
|
||||||
|
# outage would otherwise fail a bring-up that PyPI could satisfy on its
|
||||||
|
# own. When the error names an extra-index host, retry with that host
|
||||||
|
# dropped. Matched on host because wheel files may live outside the
|
||||||
|
# index path. The warning names the failing HOSTS rather than the
|
||||||
|
# configured URLs (which can carry credentials); uv's own stderr is
|
||||||
|
# included as-is, exactly as install_package logs it.
|
||||||
|
extra_urls = env.get("UV_EXTRA_INDEX_URL", "").split()
|
||||||
|
failing = {
|
||||||
|
url: host for url in extra_urls if (host := _url_host(url)) and host in stderr
|
||||||
|
}
|
||||||
|
if failing:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not install %r using extra index host %s: %s; retrying without it",
|
||||||
|
spec,
|
||||||
|
", ".join(failing.values()),
|
||||||
|
stderr,
|
||||||
|
)
|
||||||
|
retry_env = env.copy()
|
||||||
|
if remaining := [url for url in extra_urls if url not in failing]:
|
||||||
|
retry_env["UV_EXTRA_INDEX_URL"] = " ".join(remaining)
|
||||||
|
else:
|
||||||
|
del retry_env["UV_EXTRA_INDEX_URL"]
|
||||||
|
stderr = _run_uv_install(args, retry_env)
|
||||||
|
if stderr is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
_LOGGER.error("Could not install %r: %s", spec, stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _scoped_install_flags(spec: str, channel_dist: str | None) -> list[str]:
|
||||||
|
"""Return the uv flag that scopes this install to OUR distribution.
|
||||||
|
|
||||||
|
Never a bare ``--upgrade``: that re-resolves the whole graph and
|
||||||
|
replaces packages Home Assistant ships (#2135/#2146). Which scoped flag
|
||||||
|
is right depends on the SPEC SHAPE, not on which distribution it names:
|
||||||
|
|
||||||
|
* A URL requirement must be REINSTALLED. Measured on uv 0.11.33 (the
|
||||||
|
version CI pins), re-running an unchanged ``name @ file://…`` spec
|
||||||
|
reports "Checked 1 package" under both no flag and
|
||||||
|
``--upgrade-package`` — it installs nothing — while
|
||||||
|
``--reinstall-package`` replaces it. Auditing-and-skipping would keep
|
||||||
|
the OLD code running while the bring-up logs success (the #1914
|
||||||
|
shape), and it is exactly the "a URL install is always real"
|
||||||
|
guarantee that ``_replaced_dist_name`` and
|
||||||
|
``_async_remove_replaced_source`` skip their uninstall on.
|
||||||
|
* An index requirement only needs an UPGRADE, scoped to the
|
||||||
|
distribution the spec itself names. Force-reinstalling one instead
|
||||||
|
would reopen the non-atomic uninstall-then-extract window this PR
|
||||||
|
exists to close, on every bring-up, for a spec that never needed it.
|
||||||
|
|
||||||
|
A bare URL names no distribution of its own, and the channel's dist is
|
||||||
|
the wrong guess: a repository tarball installs as ``ha-mcp`` whatever
|
||||||
|
channel is selected (see :meth:`_replaced_dist_name`), so on the dev
|
||||||
|
channel scoping to ``ha-mcp-dev`` would name a package the URL does not
|
||||||
|
provide — uv would report success while leaving the real ``ha-mcp``
|
||||||
|
un-refreshed, and a mutable URL (a branch tarball, a rebuilt artifact)
|
||||||
|
keeps its version string, so nothing else would catch it. Both known
|
||||||
|
dists are therefore named: reinstalling one that is not installed is a
|
||||||
|
harmless no-op for uv (verified: exit 0, package still installed from
|
||||||
|
the URL).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
requirement = Requirement(spec)
|
||||||
|
except InvalidRequirement:
|
||||||
|
candidates = [channel_dist] if channel_dist else []
|
||||||
|
candidates += [DIST_NAME_STABLE, DIST_NAME_DEV]
|
||||||
|
flags: list[str] = []
|
||||||
|
for dist in dict.fromkeys(candidates): # ordered, deduplicated
|
||||||
|
flags += ["--reinstall-package", dist]
|
||||||
|
return flags
|
||||||
|
if requirement.url is not None:
|
||||||
|
return ["--reinstall-package", requirement.name]
|
||||||
|
return ["--upgrade-package", requirement.name]
|
||||||
|
|
||||||
|
|
||||||
|
def _url_host(url: str) -> str | None:
|
||||||
|
"""Host of ``url``, or None when it cannot be parsed.
|
||||||
|
|
||||||
|
``urlparse().hostname`` RAISES on a malformed URL (``ValueError:
|
||||||
|
Invalid IPv6 URL`` for an unclosed bracket), and the one caller runs on
|
||||||
|
the INSTALL-FAILURE path — where an operator's typo'd
|
||||||
|
``UV_EXTRA_INDEX_URL`` entry would replace uv's real stderr with a
|
||||||
|
traceback from the error handler.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return urlparse(url).hostname
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pin_moves_off_installed(spec: str, installed_version: str) -> bool:
|
||||||
|
"""True when an exact-pin ``spec`` CANNOT be satisfied by what's installed.
|
||||||
|
|
||||||
|
Asks the pin's own specifier rather than comparing parsed versions: PEP
|
||||||
|
440 ``==1.0`` matches an installed ``1.0+local``, while
|
||||||
|
``Version("1.0") != Version("1.0+local")`` is True. A version comparison
|
||||||
|
would therefore call that pin "moved", skip the caller's uninstall, and
|
||||||
|
let the installer no-op it as already satisfied — the #1914 shape.
|
||||||
|
|
||||||
|
False when the spec is not an exact pin, and False whenever the answer
|
||||||
|
is unprovable (unparseable requirement or version): "unknown" must not
|
||||||
|
be mistaken for "guaranteed to move", since the caller skips its
|
||||||
|
uninstall on a True.
|
||||||
|
"""
|
||||||
|
if _exact_pinned_version(spec) is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
requirement = Requirement(spec)
|
||||||
|
# Validate the installed version explicitly: SpecifierSet.contains()
|
||||||
|
# answers False for an unparseable version rather than raising, and
|
||||||
|
# False here would invert to "moved" — the unsafe direction.
|
||||||
|
Version(installed_version)
|
||||||
|
except (InvalidRequirement, InvalidVersion):
|
||||||
|
return False
|
||||||
|
if requirement.marker is not None and not requirement.marker.evaluate():
|
||||||
|
# The requirement does not apply to this interpreter, so the
|
||||||
|
# installer will skip it entirely — the pin cannot make the install
|
||||||
|
# real, whatever version it names.
|
||||||
|
return False
|
||||||
|
return not requirement.specifier.contains(installed_version, prereleases=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_is_url_requirement(spec: str) -> bool:
|
||||||
|
"""True when ``spec`` installs from a URL rather than an index.
|
||||||
|
|
||||||
|
Same shape test :func:`_scoped_install_flags` routes on, and for the
|
||||||
|
same reason — an unparseable spec is treated as URL-ish so it takes the
|
||||||
|
conservative path.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Requirement(spec).url is not None
|
||||||
|
except InvalidRequirement:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _uv_install_args(
|
||||||
|
spec: str,
|
||||||
|
*,
|
||||||
|
channel_dist: str | None,
|
||||||
|
constraints: str | None,
|
||||||
|
target: str | None,
|
||||||
|
env: dict[str, str],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build the ``uv pip install`` argv (mirrors install_package's shape)."""
|
||||||
|
args = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"uv",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--quiet",
|
||||||
|
spec,
|
||||||
|
# Mirrors install_package: custom components may need a different
|
||||||
|
# version of a package than the one HA built wheels for.
|
||||||
|
"--index-strategy",
|
||||||
|
"unsafe-first-match",
|
||||||
|
]
|
||||||
|
args += _scoped_install_flags(spec, channel_dist)
|
||||||
|
if constraints is not None:
|
||||||
|
args += ["--constraint", constraints]
|
||||||
|
if target:
|
||||||
|
args += ["--target", os.path.abspath(target)]
|
||||||
|
elif (
|
||||||
|
not is_virtual_env()
|
||||||
|
# install_package's _UV_ENV_PYTHON_VARS, mirrored: an explicit uv
|
||||||
|
# python selection means uv already installs to the right place.
|
||||||
|
and not any(var in env for var in ("UV_SYSTEM_PYTHON", "UV_PYTHON"))
|
||||||
|
and (user_site := site.getusersitepackages())
|
||||||
|
):
|
||||||
|
# uv has no --user (astral-sh/uv#2077); install_package's workaround.
|
||||||
|
args += ["--python", sys.executable, "--target", os.path.abspath(user_site)]
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _run_uv_install(args: list[str], env: dict[str, str]) -> str | None:
|
||||||
|
"""Run one uv install attempt; return None on success, else its stderr.
|
||||||
|
|
||||||
|
Bounded by ``_UV_INSTALL_TIMEOUT_SECONDS``: this runs inside the
|
||||||
|
process-wide tracked-install slot, and the extra-index fallback can run
|
||||||
|
it twice, so a wedged uv would otherwise pin an executor thread (and
|
||||||
|
block the next bring-up) with no upper bound. The budget is deliberately
|
||||||
|
generous — a cold ARM wheel build is slow but finite.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
args,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
timeout=_UV_INSTALL_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as err:
|
||||||
|
return f"{type(err).__name__}: {err}"
|
||||||
|
if result.returncode != 0:
|
||||||
|
return (result.stderr or "").strip() or f"exit code {result.returncode}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _exact_pinned_version(spec: str) -> str | None:
|
def _exact_pinned_version(spec: str) -> str | None:
|
||||||
"""Return the version of an exact ``==``/``===`` single-clause pin, or None.
|
"""Return the version of an exact ``==``/``===`` single-clause pin, or None.
|
||||||
|
|
||||||
|
|||||||
@@ -727,6 +727,10 @@ async def _async_update_held_by_component(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
integration = await async_get_integration(hass, DOMAIN)
|
integration = await async_get_integration(hass, DOMAIN)
|
||||||
|
if integration.version is None:
|
||||||
|
# None rather than an exception; "None" would then reach
|
||||||
|
# AwesomeVersion and compare as an ordinary string.
|
||||||
|
raise ValueError("the manifest carries no version")
|
||||||
running = str(integration.version)
|
running = str(integration.version)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Same wide loader surface as _async_check_component_compat: advisory
|
# Same wide loader surface as _async_check_component_compat: advisory
|
||||||
@@ -884,6 +888,10 @@ async def _async_check_component_compat(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
integration = await async_get_integration(hass, DOMAIN)
|
integration = await async_get_integration(hass, DOMAIN)
|
||||||
|
if integration.version is None:
|
||||||
|
# None rather than an exception; "None" would then reach
|
||||||
|
# AwesomeVersion and compare as an ordinary string.
|
||||||
|
raise ValueError("the manifest carries no version")
|
||||||
own = str(integration.version)
|
own = str(integration.version)
|
||||||
except Exception:
|
except Exception:
|
||||||
# The loader legitimately raises a wide, varied surface
|
# The loader legitimately raises a wide, varied surface
|
||||||
|
|||||||
@@ -22,5 +22,5 @@
|
|||||||
"requirements": [
|
"requirements": [
|
||||||
"ruamel.yaml>=0.18.0"
|
"ruamel.yaml>=0.18.0"
|
||||||
],
|
],
|
||||||
"version": "1.2.3"
|
"version": "1.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,15 @@ _STRIPPED_REQUEST_HEADERS = frozenset(
|
|||||||
# instead of a mislabeled JSON blob. ``text/html`` and friends stay coerced.
|
# instead of a mislabeled JSON blob. ``text/html`` and friends stay coerced.
|
||||||
_ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
|
_ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
|
||||||
|
|
||||||
# Long timeout for streamed MCP responses (matches mcp_proxy).
|
# Timeout for streamed MCP responses (matches mcp_proxy). Deliberately NO
|
||||||
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300)
|
# wall-clock ``total``: an MCP response stream is long-lived by design (the
|
||||||
|
# upcoming spec's ``subscriptions/listen`` holds one open indefinitely), so a
|
||||||
|
# ``total`` bound would cut a *healthy* stream and force the client to
|
||||||
|
# re-subscribe. ``sock_read`` bounds a *dead* one instead — idle detection, not
|
||||||
|
# elapsed time. ``connect`` stays finite: it covers connection-POOL acquisition
|
||||||
|
# (not just the TCP connect ``sock_connect`` bounds), so a pool exhausted by
|
||||||
|
# long-lived streams fails a new request in 30 s instead of hanging it forever.
|
||||||
|
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(connect=30, sock_connect=10, sock_read=300)
|
||||||
|
|
||||||
# TOP-LEVEL hass.data flag recording that the ha_auth discovery views are bound
|
# TOP-LEVEL hass.data flag recording that the ha_auth discovery views are bound
|
||||||
# for this HA session. Deliberately NOT under DOMAIN so it survives
|
# for this HA session. Deliberately NOT under DOMAIN so it survives
|
||||||
@@ -291,11 +298,31 @@ def _protected_resource_document(webhook_id: str, base: str) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_issuer(base: str) -> str:
|
||||||
|
"""Issuer identifier this component's OWN authorization servers advertise.
|
||||||
|
|
||||||
|
Single source for the ``issuer`` field of the legacy and none-mode documents
|
||||||
|
below and for the RFC 9207 ``iss`` authorization-response parameter that
|
||||||
|
:mod:`oauth_legacy` / :mod:`oauth_autoapprove` put on their redirects — RFC
|
||||||
|
9207 §2 requires the redirect's ``iss`` to equal the advertised issuer
|
||||||
|
exactly.
|
||||||
|
"""
|
||||||
|
return f"{base}{OAUTH_BASE}"
|
||||||
|
|
||||||
|
|
||||||
|
def issuer_for_request(request: web.Request) -> str:
|
||||||
|
""":func:`oauth_issuer` for the public base URL ``request`` resolves to."""
|
||||||
|
return oauth_issuer(_build_base_url(request))
|
||||||
|
|
||||||
|
|
||||||
def _legacy_authorization_server_document(base: str) -> dict[str, Any]:
|
def _legacy_authorization_server_document(base: str) -> dict[str, Any]:
|
||||||
"""RFC 8414 authorization-server metadata for legacy mode's own root
|
"""RFC 8414 authorization-server metadata for legacy mode's own root
|
||||||
``/authorize`` + ``/token`` views (see :mod:`oauth_legacy`)."""
|
``/authorize`` + ``/token`` views (see :mod:`oauth_legacy`)."""
|
||||||
return {
|
return {
|
||||||
"issuer": f"{base}{OAUTH_BASE}",
|
"issuer": oauth_issuer(base),
|
||||||
|
# RFC 9207 §3: authorization responses carry ``iss`` (oauth_legacy's
|
||||||
|
# redirects); omission reads as "not supported" to discovery clients.
|
||||||
|
"authorization_response_iss_parameter_supported": True,
|
||||||
"authorization_endpoint": f"{base}{AUTHORIZE_PATH}",
|
"authorization_endpoint": f"{base}{AUTHORIZE_PATH}",
|
||||||
"token_endpoint": f"{base}{TOKEN_PATH}",
|
"token_endpoint": f"{base}{TOKEN_PATH}",
|
||||||
"response_types_supported": ["code"],
|
"response_types_supported": ["code"],
|
||||||
@@ -323,7 +350,10 @@ def _none_mode_authorization_server_document(base: str) -> dict[str, Any]:
|
|||||||
``authorization_code`` is advertised.
|
``authorization_code`` is advertised.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"issuer": f"{base}{OAUTH_BASE}",
|
"issuer": oauth_issuer(base),
|
||||||
|
# RFC 9207 §3: authorization responses carry ``iss`` (the auto-approve
|
||||||
|
# redirects); omission reads as "not supported" to discovery clients.
|
||||||
|
"authorization_response_iss_parameter_supported": True,
|
||||||
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
|
"authorization_endpoint": f"{base}{OAUTH_BASE}/authorize",
|
||||||
"token_endpoint": f"{base}{OAUTH_BASE}/token",
|
"token_endpoint": f"{base}{OAUTH_BASE}/token",
|
||||||
"response_types_supported": ["code"],
|
"response_types_supported": ["code"],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user