348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+1 -1
View File
@@ -1 +1 @@
2026.7.2
2026.7.3
+25 -13
View File
@@ -1,14 +1,26 @@
# Home Assistant Core Exclusions
*.db
*.db-shm
*.db-wal
*.log
.storage/
# Only track specific file types
# Ignore everything by default
*
# === Home Assistant Version Control Managed (do not edit below) ===
# Tracked file types
!*.yaml
!**/.??*.yaml
!*.yml
!**/.??*.yml
# Track specific .storage configuration files
!.storage/lovelace
!.storage/lovelace_dashboards
!.storage/lovelace_resources
!.storage/lovelace.*
# === End Home Assistant Version Control Managed ===
# Allow directory traversal
!*/
# Re-ignore macOS metadata files (even if they match allowed extensions)
._*
# Excluded files
secrets.yaml
*icon*.png
*state.json
# Custom integrations and private tokens
.google_nest_prompt_info.json
nest_protect_cookies.json
+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784644362.8406641}
{"pid": 71, "version": 1, "ha_version": "2026.7.3", "start_ts": 1784830467.7871833}
+205
View File
@@ -0,0 +1,205 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.alarm_section",
"data": {
"config": {
"views": [
{
"type": "sections",
"sections": [
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading_style": "title",
"heading": "Sensors"
},
{
"type": "entities",
"entities": [
{
"entity": "binary_sensor.entryway"
},
{
"entity": "binary_sensor.basement"
},
{
"entity": "binary_sensor.office_occupancy"
},
{
"entity": "binary_sensor.downstairs_bathroom_occupancy"
},
{
"entity": "binary_sensor.night_light_occupancy"
},
{
"entity": "binary_sensor.front_door"
},
{
"entity": "binary_sensor.patio_door"
},
{
"entity": "binary_sensor.office_door_contact"
},
{
"entity": "binary_sensor.garage_door_contact"
},
{
"entity": "binary_sensor.bedroom_window"
},
{
"entity": "binary_sensor.seraphine_window"
},
{
"entity": "binary_sensor.bedroom_window"
},
{
"entity": "binary_sensor.side_window_1"
},
{
"entity": "binary_sensor.side_window_2"
},
{
"entity": "binary_sensor.swimming_pool_window"
},
{
"entity": "input_boolean.blink_backyard_camera_motion"
},
{
"entity": "input_boolean.blink_front_camera_motion"
},
{
"entity": "input_boolean.blink_shed_camera_motion"
},
{
"entity": "input_boolean.blink_inside_shed_camera_motion"
}
]
}
]
},
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading_style": "title",
"heading": "Cameras"
},
{
"square": false,
"type": "grid",
"cards": [
{
"type": "tile",
"entity": "camera.front_door_front_door",
"show_entity_picture": true
},
{
"type": "tile",
"entity": "camera.entryway_entryway_camera",
"show_entity_picture": true,
"vertical": false,
"features_position": "bottom"
},
{
"type": "tile",
"entity": "camera.living_room_living_room_camera",
"show_entity_picture": true,
"vertical": false,
"features_position": "bottom"
},
{
"type": "tile",
"entity": "camera.hallway_hallway_camera",
"show_entity_picture": true,
"vertical": false,
"features_position": "bottom"
}
],
"columns": 2
},
{
"type": "history-graph",
"entities": [
{
"entity": "input_boolean.blink_backyard_camera_motion"
},
{
"entity": "input_boolean.blink_shed_camera_motion"
},
{
"entity": "input_boolean.blink_inside_shed_camera_motion"
},
{
"entity": "input_boolean.blink_front_camera_motion"
},
{
"entity": "binary_sensor.office_occupancy"
},
{
"entity": "binary_sensor.downstairs_bathroom_occupancy"
},
{
"entity": "binary_sensor.night_light_occupancy"
},
{
"entity": "binary_sensor.basement"
},
{
"entity": "binary_sensor.entryway"
},
{
"entity": "binary_sensor.garage_door_contact"
}
],
"title": "Motion Senors",
"hours_to_show": 6,
"logarithmic_scale": false,
"expand_legend": false
},
{
"type": "history-graph",
"entities": [
{
"entity": "sensor.night_light_illuminance"
},
{
"entity": "light.night_light"
}
],
"hours_to_show": 24
},
{
"type": "custom:mushroom-entity-card",
"entity": "input_boolean.office_motion_override",
"name": "Office Motion Override",
"icon": "mdi:home-account",
"icon_color": "green",
"secondary_info": "state",
"tap_action": {
"action": "toggle"
},
"hold_action": {
"action": "none"
}
},
{
"type": "tile",
"entity": "alarm_control_panel.blainville_alarm",
"features": [
{
"type": "alarm-modes"
}
]
}
]
}
]
}
]
}
}
}
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.dashboard_music",
"data": {
"config": {
"views": [
{
"type": "sections",
"sections": [
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading": "Amazon Devices",
"heading_style": "title"
},
{
"type": "vertical-stack",
"cards": [
{
"type": "entities",
"title": "Alexa Music Router",
"show_header_toggle": false,
"entities": [
{
"entity": "input_select.alexa_playlist",
"name": "Select Playlist"
},
{
"entity": "input_select.alexa_target_device",
"name": "Target Speaker"
},
{
"entity": "input_number.alexa_master_volume",
"name": "Speaker Volume"
}
]
},
{
"type": "grid",
"columns": 2,
"square": false,
"cards": [
{
"type": "button",
"name": "Play Music",
"icon": "mdi:play",
"tap_action": {
"action": "call-service",
"service": "script.dynamic_alexa_spotify_player"
}
},
{
"type": "button",
"name": "Stop Music",
"icon": "mdi:stop",
"tap_action": {
"action": "call-service",
"service": "script.stop_alexa_music"
}
}
]
}
]
}
]
},
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading": "Office Speaker",
"heading_style": "title"
},
{
"type": "custom:mass-player-card",
"entities": [
"media_player.office_speaker_2",
"media_player.francos_macbook_pro"
],
"panel": true,
"sync_player_across_dashboard": true,
"media_browser": {
"recommendations": {}
}
}
]
},
{
"type": "grid",
"cards": [
{
"type": "custom:my-music-library-card",
"entity": "media_player.office_speaker_2",
"grid_options": {
"columns": 12,
"rows": "auto"
}
}
],
"column_span": 1
},
{
"type": "grid",
"cards": [
{
"type": "iframe",
"url": "/d5369777_music_assistant",
"aspect_ratio": "75%",
"grid_options": {
"columns": "full",
"rows": 8
}
}
],
"column_span": 2
}
]
}
]
}
}
}
+538
View File
@@ -0,0 +1,538 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.dashboard_temp",
"data": {
"config": {
"views": [
{
"type": "sections",
"sections": [
{
"type": "grid",
"cards": [
{
"type": "custom:uptime-card",
"entity": "sensor.nas_status",
"name": "NAS Availability",
"icon": "mdi:nas",
"ok": [
"up",
"Up",
"online"
],
"ko": [
"down",
"Down",
"offline"
],
"alias": {
"ok": "Up",
"ko": "Down"
},
"show_state": true,
"bar": {
"amount": 30
},
"title_adaptive_color": true,
"status_adaptive_color": false,
"icon_adaptive_color": true
},
{
"type": "custom:uptime-card",
"entity": "sensor.homeassistant_status",
"name": "NAS Availability",
"icon": "mdi:nas",
"ok": [
"up",
"Up",
"online"
],
"ko": [
"down",
"Down",
"offline"
],
"alias": {
"ok": "Up",
"ko": "Down"
},
"show_state": true,
"bar": {
"amount": 30
},
"title_adaptive_color": true,
"status_adaptive_color": false,
"icon_adaptive_color": true
},
{
"type": "custom:uptime-card",
"entity": "sensor.ollama_status",
"name": "NAS Availability",
"icon": "mdi:nas",
"ok": [
"up",
"Up",
"online"
],
"ko": [
"down",
"Down",
"offline"
],
"alias": {
"ok": "Up",
"ko": "Down"
},
"show_state": true,
"bar": {
"amount": 30
},
"title_adaptive_color": true,
"status_adaptive_color": false,
"icon_adaptive_color": true
},
{
"type": "custom:uptime-card",
"entity": "sensor.next_cloud_status",
"name": "NAS Availability",
"icon": "mdi:nas",
"ok": [
"up",
"Up",
"online"
],
"ko": [
"down",
"Down",
"offline"
],
"alias": {
"ok": "Up",
"ko": "Down"
},
"show_state": true,
"bar": {
"amount": 30
},
"title_adaptive_color": true,
"status_adaptive_color": false,
"icon_adaptive_color": true
},
{
"type": "sensor",
"entity": "sensor.nas_response_time",
"name": "NAS Latency",
"graph": "line",
"hours_to_show": 24
},
{
"type": "sensor",
"entity": "sensor.homeassistant_response_time",
"name": "HA Latency",
"graph": "line",
"hours_to_show": 24
},
{
"graph": "line",
"type": "sensor",
"entity": "sensor.ollama_response_time",
"hours_to_show": 24,
"detail": 1
},
{
"graph": "line",
"type": "sensor",
"entity": "sensor.next_cloud_response_time",
"hours_to_show": 24,
"detail": 1
},
{
"graph": "line",
"type": "sensor",
"entity": "sensor.homeassistant_certificate_expiry",
"hours_to_show": 24,
"detail": 1
},
{
"graph": "line",
"type": "sensor",
"entity": "sensor.next_cloud_certificate_expiry",
"hours_to_show": 24,
"detail": 1
}
]
},
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading": "HVAC Stats",
"heading_style": "title"
},
{
"type": "grid",
"square": false,
"columns": 1,
"cards": [
{
"type": "statistics-graph",
"title": "Daily Breakdown (Last 7 Days)",
"chart_type": "bar",
"period": "day",
"days_to_show": 7,
"stat_types": [
"change"
],
"entities": [
"sensor.nest_cooling_time_stats",
"sensor.nest_heating_time_stats"
]
},
{
"type": "statistics-graph",
"title": "Monthly Trend (Last 6 Months)",
"chart_type": "line",
"period": "month",
"days_to_show": 180,
"stat_types": [
"max",
"change"
],
"entities": [
"sensor.nest_cooling_monthly",
"sensor.nest_heating_monthly"
]
},
{
"type": "statistics-graph",
"title": "Yearly Comparison",
"chart_type": "bar",
"period": "month",
"days_to_show": 365,
"stat_types": [
"max",
"change"
],
"entities": [
"sensor.nest_cooling_yearly",
"sensor.nest_heating_yearly"
]
}
]
},
{
"type": "statistics-graph",
"chart_type": "bar",
"period": "day",
"days_to_show": 7,
"stat_types": [
"change"
],
"entities": [
{
"entity": "sensor.nest_cooling_weekly",
"name": "Cooling"
},
{
"entity": "sensor.nest_heating_weekly",
"name": "Heating"
}
],
"title": "Weekly HVAC Usage Breakdown"
},
{
"type": "heading",
"heading": "Dishwasher"
},
{
"type": "tile",
"entity": "binary_sensor.150633095332665_door",
"icon": "mdi:door-open",
"vertical": false,
"features_position": "bottom"
},
{
"type": "tile",
"entity": "sensor.150633095332665_status"
},
{
"type": "tile",
"entity": "sensor.150633095332665_progress"
},
{
"type": "tile",
"entity": "binary_sensor.150633095332665_rinse_aid"
},
{
"type": "tile",
"entity": "sensor.150633095332665_temperature"
},
{
"type": "tile",
"entity": "sensor.150633095332665_mode"
},
{
"type": "tile",
"entity": "sensor.150633095332665_bright"
},
{
"type": "tile",
"entity": "sensor.150633095332665_error_code"
},
{
"type": "tile",
"entity": "sensor.150633095332665_softwater"
},
{
"type": "tile",
"entity": "sensor.150633095332665_time_remaining"
},
{
"type": "tile",
"entity": "binary_sensor.150633095332665_salt"
},
{
"type": "tile",
"entity": "counter.dishwasher_wash_cycles"
},
{
"type": "tile",
"entity": "counter.dishwasher_total_wash_cycles"
}
]
},
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading": "New section"
},
{
"type": "custom:album-slideshow-card",
"entity": "camera.album_slideshow_google_screensaver"
},
{
"type": "entities",
"title": "Network Status",
"entities": [
{
"entity": "sensor.gt_ax11000_connected_devices",
"name": "Connected Clients",
"icon": "mdi:devices"
},
{
"entity": "sensor.gt_ax11000_wan_download_speed",
"name": "Download Speed",
"icon": "mdi:download-network"
},
{
"entity": "sensor.gt_ax11000_wan_upload_speed",
"name": "Upload Speed",
"icon": "mdi:upload-network"
},
{
"entity": "sensor.gt_ax11000_wired_download_speed",
"name": "Wired D/L"
},
{
"entity": "sensor.gt_ax11000_wired_upload_speed",
"name": "Wired U/L"
}
]
},
{
"type": "custom:mini-graph-card",
"title": "Network Traffic (24h)",
"entities": [
{
"entity": "sensor.gt_ax11000_wan_download_speed",
"name": "Download",
"color": "#2196F3"
},
{
"entity": "sensor.gt_ax11000_wan_upload_speed",
"name": "Upload",
"color": "#4CAF50"
}
],
"hours_to_show": 6,
"points_per_hour": 4,
"line_width": 4,
"fill": "fade",
"show": {
"graph": "line",
"fill": true,
"state": false,
"name": false,
"extrema": false,
"average": false
}
},
{
"type": "picture-glance",
"title": "Backyard Shed",
"camera_image": "camera.backyard_shed",
"camera_view": "live",
"entities": [],
"tap_action": {
"action": "call-service",
"service": "camera.turn_on",
"target": {
"entity_id": "camera.backyard_shed"
}
}
},
{
"type": "statistics-graph",
"entities": [
"sensor.gt_ax11000_wan_download_speed",
"sensor.gt_ax11000_wan_upload_speed"
],
"days_to_show": 1,
"period": "hour",
"chart_type": "line",
"stat_types": [
"mean"
]
}
]
},
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading": "New section"
},
{
"type": "custom:treemap-card",
"entities": [
"sensor.*power"
],
"exclude": [
"*meter00*"
]
},
{
"type": "custom:auto-entities",
"card": {
"type": "grid",
"columns": 2,
"square": false
},
"filter": {
"include": [
{
"domain": "light",
"options": {
"type": "custom:mushroom-light-card",
"use_light_color": true,
"show_brightness_control": true,
"show_color_temp_control": true,
"collapsible_controls": true,
"tap_action": {
"action": "toggle"
}
}
}
]
}
}
]
},
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading": "New section"
},
{
"type": "custom:simple-thermostat",
"entity": "climate.living_room_living_room"
},
{
"type": "custom:simple-thermostat",
"entity": "climate.vt_basement"
}
]
},
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading": "Blink Backyard Tree"
},
{
"type": "tile",
"entity": "camera.blink_backyard_tree",
"show_entity_picture": true
},
{
"type": "tile",
"entity": "binary_sensor.blink_backyard_tree_motion"
},
{
"type": "tile",
"entity": "sensor.blink_backyard_tree_temperature"
}
]
}
],
"max_columns": 4,
"title": "Main",
"cards": []
},
{
"type": "panel",
"path": "",
"cards": [
{
"type": "custom:climate-scheduler-card"
}
],
"title": "Climate"
},
{
"type": "sections",
"max_columns": 4,
"title": "TaskMate",
"path": "test",
"sections": [
{
"type": "grid",
"cards": [
{
"type": "custom:taskmate-overview-card",
"entity": "sensor.taskmate_overview",
"title": "TaskMate",
"child": "Seraphine Skyler"
}
]
},
{
"type": "grid",
"cards": [
{
"type": "heading",
"heading": "Overview",
"heading_style": "title"
},
{
"type": "custom:taskmate-calendar-card",
"entity": "sensor.taskmate_overview",
"title": "Task Calendar"
}
]
}
],
"cards": []
}
]
}
}
}
+145
View File
@@ -0,0 +1,145 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.in_meeting",
"data": {
"config": {
"wallpanel": {
"enabled": true,
"hide_toolbar": false,
"hide_sidebar": true,
"fullscreen": false,
"idle_time": 0
},
"views": [
{
"title": "Main",
"path": "main",
"type": "panel",
"cards": [
{
"type": "grid",
"columns": 2,
"square": false,
"cards": [
{
"type": "vertical-stack",
"cards": [
{
"show_name": true,
"show_icon": true,
"type": "button",
"entity": "input_boolean.in_a_meeting",
"name": "OFFICE MEETING STATUS",
"show_state": true,
"icon": "mdi:door-open",
"icon_height": "200px"
},
{
"type": "conditional",
"conditions": [
{
"condition": "state",
"entity": "input_boolean.in_a_meeting",
"state": "off"
}
],
"card": {
"show_name": true,
"show_icon": false,
"type": "button",
"entity": "input_boolean.in_a_meeting",
"name": "🟢 AVAILABLE - COME IN",
"show_state": false,
"icon_height": "100px"
}
},
{
"type": "conditional",
"conditions": [
{
"condition": "state",
"entity": "input_boolean.in_a_meeting",
"state": "on"
}
],
"card": {
"type": "button",
"entity": "input_boolean.in_a_meeting",
"name": "🔴 DO NOT ENTER - IN A MEETING",
"show_state": false,
"show_icon": false
}
},
{
"type": "entities",
"entities": [
{
"entity": "input_boolean.in_a_meeting",
"name": "Manual Desk Toggle",
"icon": "mdi:power"
}
]
}
]
},
{
"type": "vertical-stack",
"cards": [
{
"type": "markdown",
"title": "Desk Companion",
"content": "### System Status This dashboard displays your live office configuration. Outside of working hours, the system will seamlessly shift into motion-activated night light mode.\n"
},
{
"type": "vertical-stack",
"cards": [
{
"type": "markdown",
"content": "### Live Hardware Feed (Outside Door)"
},
{
"type": "tile",
"entity": "light.night_light",
"name": "Door Light Color State",
"state_content": "state",
"state_color": true
},
{
"type": "entities",
"show_header_toggle": false,
"state_color": true,
"entities": [
{
"entity": "binary_sensor.night_light_occupancy",
"name": "Night Light Motion Sensor",
"icon": "mdi:motion-sensor"
}
]
},
{
"type": "custom:mushroom-light-card",
"entity": "light.bedroom_lights",
"use_light_color": true,
"tap_action": {
"action": "none"
},
"hold_action": {
"action": "none"
},
"double_tap_action": {
"action": "none"
}
}
]
}
]
}
]
}
]
}
]
}
}
}
+424
View File
@@ -0,0 +1,424 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.kobo_dashboard",
"data": {
"config": {
"wallpanel": {
"enabled": true,
"hide_toolbar": false,
"hide_sidebar": true,
"fullscreen": false,
"idle_time": 0
},
"views": [
{
"type": "sections",
"sections": [
{
"type": "grid",
"cards": [
{
"type": "weather-forecast",
"entity": "weather.forecast_home",
"forecast_type": "hourly"
},
{
"type": "vertical-stack",
"cards": [
{
"type": "grid",
"square": false,
"columns": 2,
"cards": [
{
"type": "entity",
"entity": "sensor.living_room_living_room_temperature",
"name": "Living Room Temp"
},
{
"type": "entity",
"entity": "sensor.basement_temperature",
"name": "Basement Temp"
}
]
}
]
},
{
"type": "vertical-stack",
"cards": [
{
"type": "grid",
"square": false,
"columns": 2,
"cards": [
{
"type": "entity",
"entity": "sensor.pool_temperature",
"name": "Pool Temperature"
},
{
"type": "entity",
"entity": "sensor.seatemperatures_sandbanks_today",
"name": "Sandbanks"
}
]
}
]
},
{
"type": "vertical-stack",
"cards": [
{
"type": "grid",
"square": false,
"columns": 2,
"cards": [
{
"type": "entity",
"entity": "input_boolean.in_a_meeting",
"name": "In Meeting",
"icon": "mdi:office-building",
"state_color": true
},
{
"type": "custom:button-card",
"entity": "light.night_light",
"name": "Night Light",
"show_icon": false,
"show_state": false,
"show_label": true,
"label": "[[[\n if (states['light.night_light'].state === 'off') return 'Off';\n const rgb = states['light.night_light'].attributes.rgb_color;\n if (!rgb) return states['light.night_light'].state;\n if (rgb[0] > rgb[1] && rgb[0] > rgb[2]) return 'Red';\n if (rgb[1] > rgb[0] && rgb[1] > rgb[2]) return 'Green';\n return 'Other Color';\n]]]\n",
"styles": {
"label": [
{
"font-weight": "bold"
},
{
"color": "[[[\n if (states['light.night_light'].state === 'off') return '#888888';\n const rgb = states['light.night_light'].attributes.rgb_color;\n if (!rgb) return 'var(--primary-text-color)';\n if (rgb[0] > rgb[1] && rgb[0] > rgb[2]) return '#ff4d4d';\n if (rgb[1] > rgb[0] && rgb[1] > rgb[2]) return '#4dff4d';\n return 'var(--primary-text-color)';\n]]]\n"
}
]
}
}
]
}
]
}
]
},
{
"type": "grid",
"cards": [
{
"entities": [
{
"entity": "calendar.family_calendar",
"show_time": true,
"show_description": true,
"split_multiday_events": false
}
],
"weather": {
"position": "date",
"date": {
"show_conditions": true,
"show_high_temp": true,
"show_low_temp": false,
"show_uv_index": false,
"uv_index_threshold": 0,
"icon_size": "14px",
"font_size": "12px",
"color": "var(--primary-text-color)"
},
"event": {
"show_conditions": true,
"show_temp": true,
"show_uv_index": false,
"uv_index_threshold": 0,
"icon_size": "14px",
"font_size": "12px",
"color": "var(--primary-text-color)"
},
"entity": "weather.forecast_home"
},
"type": "custom:calendar-card-pro"
},
{
"type": "vertical-stack",
"cards": [
{
"type": "gauge",
"entity": "counter.dishwasher_wash_cycles",
"name": "Dishwasher Filter Life",
"min": 0,
"max": 50,
"needle": true,
"severity": {
"green": 0,
"yellow": 10,
"red": 30
}
}
],
"grid_options": {
"columns": 6,
"rows": "auto"
}
},
{
"type": "vertical-stack",
"cards": [
{
"type": "custom:button-card",
"entity": "binary_sensor.150633095332665_door",
"name": "Dishwasher Door",
"show_state": true,
"show_icon": false,
"custom_fields": {
"custom_icon": "[[[\n let iconName = entity.state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';\n let iconColor = entity.state === 'on' ? 'var(--paper-item-icon-active-color, #fdd835)' : 'var(--paper-item-icon-color, #44739e)';\n return `<ha-icon icon=\"${iconName}\" style=\"width: 24px; height: 24px; color: ${iconColor};\"></ha-icon>`;\n]]]\n"
},
"styles": {
"grid": [
{
"grid-template-areas": "\"custom_icon s\" \"n n\""
},
{
"grid-template-columns": "auto 1fr"
},
{
"grid-template-rows": "auto auto"
}
],
"card": [
{
"padding": "12px"
},
{
"border-radius": "12px"
},
{
"height": "82px"
}
],
"custom_fields": {
"custom_icon": [
{
"justify-self": "start"
},
{
"align-self": "center"
},
{
"margin-right": "8px"
},
{
"display": "flex"
}
]
},
"state": [
{
"font-size": "28px"
},
{
"font-weight": "bold"
},
{
"justify-self": "start"
},
{
"align-self": "center"
},
{
"text-transform": "capitalize"
}
],
"name": [
{
"justify-self": "start"
},
{
"font-size": "14px"
},
{
"color": "var(--secondary-text-color)"
},
{
"margin-top": "4px"
}
]
}
},
{
"type": "custom:button-card",
"entity": "sensor.150633095332665_temperature",
"name": "Dishwasher Temp",
"show_state": true,
"show_icon": false,
"custom_fields": {
"custom_icon": "[[[\n let temp = parseFloat(entity.state);\n let iconColor = temp > 40 ? '#ef5350' : 'var(--paper-item-icon-color, #44739e)';\n return `<ha-icon icon=\"mdi:thermometer\" style=\"width: 24px; height: 24px; color: ${iconColor};\"></ha-icon>`;\n]]]\n"
},
"styles": {
"grid": [
{
"grid-template-areas": "\"custom_icon s\" \"n n\""
},
{
"grid-template-columns": "auto 1fr"
},
{
"grid-template-rows": "auto auto"
}
],
"card": [
{
"padding": "12px"
},
{
"border-radius": "12px"
},
{
"height": "82px"
}
],
"custom_fields": {
"custom_icon": [
{
"justify-self": "start"
},
{
"align-self": "center"
},
{
"margin-right": "8px"
},
{
"display": "flex"
}
]
},
"state": [
{
"font-size": "28px"
},
{
"font-weight": "bold"
},
{
"justify-self": "start"
},
{
"align-self": "center"
},
{
"text-transform": "capitalize"
}
],
"name": [
{
"justify-self": "start"
},
{
"font-size": "14px"
},
{
"color": "var(--secondary-text-color)"
},
{
"margin-top": "4px"
}
]
}
}
],
"grid_options": {
"columns": 6,
"rows": "auto"
}
},
{
"type": "vertical-stack",
"cards": [
{
"type": "conditional",
"conditions": [
{
"condition": "state",
"entity": "input_boolean.in_a_meeting",
"state": "off"
}
],
"card": {
"show_name": true,
"show_icon": false,
"type": "button",
"entity": "input_boolean.in_a_meeting",
"name": "✅ AVAILABLE - COME IN",
"show_state": false
}
},
{
"type": "conditional",
"conditions": [
{
"condition": "state",
"entity": "input_boolean.in_a_meeting",
"state": "on"
}
],
"card": {
"show_name": true,
"show_icon": false,
"type": "button",
"entity": "input_boolean.in_a_meeting",
"name": "✋ DO NOT ENTER - IN A MEETING",
"show_state": false
}
}
]
},
{
"type": "vertical-stack",
"cards": [
{
"type": "grid",
"square": false,
"columns": 2,
"cards": [
{
"type": "entity",
"entity": "sensor.google_travel_time_work",
"name": "Work"
},
{
"type": "entity",
"entity": "sensor.google_travel_time_parents",
"name": "Parents"
}
]
}
]
}
]
},
{
"type": "grid",
"cards": [
{
"show_state": false,
"show_name": false,
"camera_view": "auto",
"fit_mode": "cover",
"type": "picture-entity",
"entity": "camera.album_slideshow_google_screensaver",
"camera_image": "camera.album_slideshow_google_screensaver"
}
]
}
]
}
]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.map",
"data": {
"config": {
"strategy": {
"type": "map"
}
}
}
}
+577
View File
@@ -0,0 +1,577 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.morning_hub",
"data": {
"config": {
"title": "Morning Hub",
"views": [
{
"title": "Morning",
"path": "morning",
"icon": "mdi:weather-sunset-up",
"type": "sections",
"max_columns": 3,
"sections": [
{
"cards": [
{
"type": "heading",
"heading": "Overview"
},
{
"type": "custom:clock-weather-card",
"entity": "weather.pirateweather"
},
{
"type": "vertical-stack",
"cards": [
{
"type": "custom:button-card",
"name": "Commute",
"icon": "mdi:car",
"styles": {
"card": [
{
"border-radius": "16px 16px 0px 0px"
},
{
"padding": "12px 16px"
},
{
"border-bottom": "1px solid #f1f2f6"
}
],
"grid": [
{
"grid-template-areas": "\"i n\""
},
{
"grid-template-columns": "30px 1fr"
}
],
"name": [
{
"font-weight": "bold"
},
{
"font-size": "16px"
}
],
"icon": [
{
"color": "#1e90ff"
}
]
}
},
{
"type": "custom:button-card",
"entity": "sensor.google_travel_time_work",
"show_name": true,
"show_label": true,
"name": "Google Maps",
"label": "via Autoroute 13 S",
"styles": {
"card": [
{
"border-radius": "0px"
},
{
"padding": "12px 16px"
},
{
"border-bottom": "1px solid #f1f2f6"
}
],
"grid": [
{
"grid-template-areas": "\"i n stat b\""
},
{
"grid-template-columns": "40px 1fr auto auto"
}
],
"icon": [
{
"color": "#4285F4"
}
],
"name": [
{
"font-weight": "bold"
},
{
"justify-self": "start"
}
],
"label": [
{
"font-size": "12px"
},
{
"color": "gray"
},
{
"justify-self": "start"
}
]
},
"custom_fields": {
"stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]",
"b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `<span style=\"background-color: ${bg}; color: ${clr}; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-left: 10px;\">${txt}</span>`;\n]]]\n"
}
},
{
"type": "custom:button-card",
"entity": "sensor.waze_to_work_travel_time",
"show_name": true,
"show_label": true,
"name": "Waze",
"label": "via Autoroute 13 S",
"styles": {
"card": [
{
"border-radius": "0px"
},
{
"padding": "12px 16px"
},
{
"border-bottom": "1px solid #f1f2f6"
}
],
"grid": [
{
"grid-template-areas": "\"i n stat b\""
},
{
"grid-template-columns": "40px 1fr auto auto"
}
],
"icon": [
{
"color": "#33ccff"
}
],
"name": [
{
"font-weight": "bold"
},
{
"justify-self": "start"
}
],
"label": [
{
"font-size": "12px"
},
{
"color": "gray"
},
{
"justify-self": "start"
}
]
},
"custom_fields": {
"stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]",
"b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `<span style=\"background-color: ${bg}; color: ${clr}; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-left: 10px;\">${txt}</span>`;\n]]]\n"
}
},
{
"type": "custom:button-card",
"entity": "sensor.google_travel_time_work",
"show_name": true,
"show_label": true,
"name": "[[[ \n var mins = Math.round(parseFloat(entity.state));\n var status = mins < 35 ? 'Traffic is flowing normally' : (mins < 45 ? 'Minor traffic delays' : 'Heavy traffic alerts');\n return `${status} • ${mins} min`;\n]]]\n",
"label": "Typical commute: ~25 min",
"icon": "[[[ return parseFloat(entity.state) < 35 ? 'mdi:check-circle' : (parseFloat(entity.state) < 45 ? 'mdi:alert-circle' : 'mdi:alert-octagon'); ]]]",
"styles": {
"card": [
{
"border-radius": "0px 0px 16px 16px"
},
{
"padding": "12px 16px"
}
],
"grid": [
{
"grid-template-areas": "\"i n\""
},
{
"grid-template-columns": "40px 1fr"
}
],
"icon": [
{
"color": "[[[ return parseFloat(entity.state) < 35 ? '#2ecc71' : (parseFloat(entity.state) < 45 ? '#e67e22' : '#e74c3c'); ]]]"
}
],
"name": [
{
"font-weight": "500"
},
{
"justify-self": "start"
}
],
"label": [
{
"font-size": "12px"
},
{
"color": "gray"
},
{
"justify-self": "start"
}
]
}
}
]
},
{
"type": "heading",
"heading": "Control"
},
{
"type": "vertical-stack",
"cards": [
{
"type": "custom:button-card",
"name": "Quick Actions",
"icon": "mdi:rocket-launch",
"styles": {
"card": [
{
"background": "none"
},
{
"box-shadow": "none"
},
{
"padding": "4px 0px"
}
],
"grid": [
{
"grid-template-areas": "\"i n\""
},
{
"grid-template-columns": "30px 1fr"
}
],
"name": [
{
"font-weight": "bold"
},
{
"font-size": "16px"
}
],
"icon": [
{
"color": "#9b59b6"
}
]
}
},
{
"type": "grid",
"square": false,
"columns": 1,
"cards": [
{
"type": "custom:button-card",
"name": "Open Waze",
"icon": "mdi:waze",
"tap_action": {
"action": "url",
"url_path": "https://waze.com/ul"
},
"styles": {
"card": [
{
"background-color": "#3498db"
},
{
"border-radius": "16px"
},
{
"color": "white"
},
{
"padding": "18px 16px"
}
],
"grid": [
{
"grid-template-areas": "\"i n\""
},
{
"grid-template-columns": "40px 1fr"
},
{
"justify-items": "start"
}
],
"name": [
{
"font-weight": "bold"
},
{
"font-size": "16px"
}
],
"icon": [
{
"width": "26px"
}
]
}
},
{
"type": "custom:button-card",
"name": "Open Google Maps",
"icon": "mdi:google-maps",
"tap_action": {
"action": "url",
"url_path": "https://maps.google.com"
},
"styles": {
"card": [
{
"background-color": "#2ecc71"
},
{
"border-radius": "16px"
},
{
"color": "white"
},
{
"padding": "18px 16px"
}
],
"grid": [
{
"grid-template-areas": "\"i n\""
},
{
"grid-template-columns": "40px 1fr"
},
{
"justify-items": "start"
}
],
"name": [
{
"font-weight": "bold"
},
{
"font-size": "16px"
}
],
"icon": [
{
"width": "26px"
}
]
}
}
]
}
]
}
]
},
{
"cards": [
{
"type": "heading",
"heading": "Schedule"
},
{
"title": "Today's Calendar",
"entities": [
"calendar.family_calendar"
],
"default_view": "agenda",
"first_day_of_week": 0,
"week_days": [
0,
1,
2,
3,
4,
5,
6
],
"week_start_hour": 0,
"week_end_hour": 23,
"lock_schedule_hours": false,
"hide_the_past": false,
"past_event_mode": "none",
"disable_swipe_controls": false,
"show_all_events_month": false,
"show_all_details_month": false,
"hide_empty_days": true,
"agenda_compact_events": false,
"shorten_event_times": false,
"time_zone": "",
"display_full_weekday_names": false,
"compact_width": false,
"show_current_time_bar": false,
"show_event_location": false,
"use_short_location": false,
"event_location_font_size": 9,
"background_opacity": 0,
"header_background_opacity": 0,
"event_calendar_friendly_name": false,
"event_title_prefix": "none",
"combine_style": "bars",
"combine_background": "primary",
"event_color_mode": "classic",
"event_neutral_background": "#F8F3E9",
"event_tint_opacity": 80,
"event_color_bar_width": 18,
"day_badges": [],
"day_badge_layout_week": "inline",
"hide_calendars": true,
"hide_header": true,
"hide_year": false,
"hide_controls": false,
"hide_navigation_buttons": false,
"hide_add_event_button": false,
"hide_view_selector": false,
"hide_dark_mode_toggle": false,
"show_dashboard_nav_button": false,
"header_dashboard_path": null,
"header_weather_sensor": "",
"calendar_person_entities": {},
"default_hidden_calendars": [],
"color_scheme": "auto",
"enable_event_management": true,
"event_modal_size": "medium",
"type": "custom:daylight-calendar-card",
"rolling_days": 7,
"compact_height": true,
"card_mod": {
"style": "ha-card {\n border-radius: 18px !important;\n padding: 16px !important;\n background-color: transparent !important;\n box-shadow: none !important;\n border: none !important;\n}\n.daylight-header-title {\n font-weight: bold !important;\n font-size: 18px !important;\n color: var(--primary-text-color) !important;\n}\n.daylight-event-wrapper {\n background: #ffffff !important;\n border: 1px solid #e1e4e8 !important;\n border-radius: 14px !important;\n margin-bottom: 12px !important;\n padding: 14px 16px !important;\n box-shadow: 0 4px 12px rgba(0,0,0,0.02) !important;\n border-left: 5px solid var(--daylight-event-color, #2196f3) !important;\n}\n.daylight-event-title {\n font-weight: bold !important;\n font-size: 15px !important;\n color: #2c3e50 !important;\n}\n.daylight-event-time {\n font-size: 12px !important;\n color: #95a5a6 !important;\n margin-bottom: 2px !important;\n}\n"
},
"hide_calendar_names": false,
"grid_options": {
"rows": 5,
"columns": 12
}
},
{
"type": "statistics-graph",
"entities": [
"sensor.google_travel_time_work"
],
"days_to_show": 1,
"period": "hour",
"chart_type": "line",
"stat_types": [
"mean"
]
},
{
"type": "statistics-graph",
"entities": [
"sensor.waze_to_work_travel_time_waze_to_parents"
],
"days_to_show": 1,
"period": "hour",
"chart_type": "line",
"stat_types": [
"mean"
]
}
]
},
{
"type": "grid",
"cards": [
{
"type": "custom:button-card",
"name": "[[[\n const hour = new Date().getHours();\n if (hour >= 5 && hour < 12) {\n return \"Good Morning, Franco!\";\n } else if (hour >= 12 && hour < 17) {\n return \"Good Afternoon, Franco!\";\n } else {\n return \"Good Evening, Franco!\";\n }\n]]]",
"label": "[[[ const lnow = new Date(); return lnow.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); ]]]",
"show_label": true,
"styles": {
"card": [
{
"background": "[[[\n const hour = new Date().getHours();\n \n if (hour >= 5 && hour < 11) {\n // Sunrise: Soft orange to warm pink dawn gradient\n return 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #feada6 100%)';\n } else if (hour >= 11 && hour < 16) {\n // Mid-day: Bright, vibrant clear sky blue gradient\n return 'linear-gradient(135deg, #2980b9 0%, #6dd5fa 50%, #ffffff 100%)';\n } else if (hour >= 16 && hour < 21) {\n // Sunset: Deep twilight purple to vibrant orange gradient\n return 'linear-gradient(135deg, #111827 0%, #701a75 40%, #f59e0b 100%)';\n } else {\n // Night: Deep cosmic midnight blue and space gray gradient\n return 'linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #334155 100%)';\n }\n]]]"
},
{
"background-size": "cover"
},
{
"background-position": "center"
},
{
"height": "110px"
},
{
"border-radius": "18px"
},
{
"padding": "16px"
}
],
"name": [
{
"justify-self": "start"
},
{
"font-weight": "bold"
},
{
"font-size": "22px"
},
{
"color": "white"
},
{
"text-shadow": "0px 2px 4px rgba(0,0,0,0.2)"
}
],
"label": [
{
"justify-self": "start"
},
{
"font-size": "14px"
},
{
"color": "white"
},
{
"opacity": "0.9"
},
{
"text-shadow": "0px 1px 3px rgba(0,0,0,0.2)"
}
]
}
},
{
"show_state": false,
"show_name": false,
"camera_view": "auto",
"fit_mode": "cover",
"type": "picture-entity",
"entity": "camera.album_slideshow_google_screensaver",
"camera_image": "camera.album_slideshow_google_screensaver"
}
]
}
]
}
]
}
}
}
+345
View File
@@ -0,0 +1,345 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.morning_ipad",
"data": {
"config": {
"wallpanel": {
"enabled": true,
"hide_toolbar": false,
"hide_sidebar": true,
"fullscreen": false,
"idle_time": 0
},
"title": "Morning Hub",
"views": [
{
"title": "Morning",
"path": "morning",
"icon": "mdi:weather-sunset-up",
"type": "sections",
"max_columns": 3,
"sections": [
{
"cards": [
{
"type": "heading",
"heading": "Overview"
},
{
"type": "custom:clock-weather-card",
"entity": "weather.pirateweather"
},
{
"type": "vertical-stack",
"cards": [
{
"type": "custom:button-card",
"name": "Commute",
"icon": "mdi:car",
"styles": {
"card": [
{
"border-radius": "16px 16px 0px 0px"
},
{
"padding": "12px 16px"
},
{
"border-bottom": "1px solid #f1f2f6"
}
],
"grid": [
{
"grid-template-areas": "\"i n\""
},
{
"grid-template-columns": "30px 1fr"
}
],
"name": [
{
"font-weight": "bold"
},
{
"font-size": "16px"
}
],
"icon": [
{
"color": "#1e90ff"
}
]
}
},
{
"type": "custom:button-card",
"entity": "sensor.google_travel_time_work",
"show_name": true,
"show_label": true,
"name": "Google Maps",
"label": "via Autoroute 13 S",
"styles": {
"card": [
{
"border-radius": "0px"
},
{
"padding": "12px 16px"
},
{
"border-bottom": "1px solid #f1f2f6"
}
],
"grid": [
{
"grid-template-areas": "\"i n stat b\""
},
{
"grid-template-columns": "40px 1fr auto auto"
}
],
"icon": [
{
"color": "#4285F4"
}
],
"name": [
{
"font-weight": "bold"
},
{
"justify-self": "start"
}
],
"label": [
{
"font-size": "12px"
},
{
"color": "gray"
},
{
"justify-self": "start"
}
]
},
"custom_fields": {
"stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]",
"b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `<span style=\"background-color: ${bg}; color: ${clr}; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-left: 10px;\">${txt}</span>`;\n]]]\n"
}
},
{
"type": "custom:button-card",
"entity": "sensor.waze_to_work_travel_time",
"show_name": true,
"show_label": true,
"name": "Waze",
"label": "via Autoroute 13 S",
"styles": {
"card": [
{
"border-radius": "0px"
},
{
"padding": "12px 16px"
},
{
"border-bottom": "1px solid #f1f2f6"
}
],
"grid": [
{
"grid-template-areas": "\"i n stat b\""
},
{
"grid-template-columns": "40px 1fr auto auto"
}
],
"icon": [
{
"color": "#33ccff"
}
],
"name": [
{
"font-weight": "bold"
},
{
"justify-self": "start"
}
],
"label": [
{
"font-size": "12px"
},
{
"color": "gray"
},
{
"justify-self": "start"
}
]
},
"custom_fields": {
"stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]",
"b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `<span style=\"background-color: ${bg}; color: ${clr}; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; margin-left: 10px;\">${txt}</span>`;\n]]]\n"
}
},
{
"type": "custom:button-card",
"entity": "sensor.google_travel_time_work",
"show_name": true,
"show_label": true,
"name": "[[[ \n var mins = Math.round(parseFloat(entity.state));\n var status = mins < 35 ? 'Traffic normally' : (mins < 45 ? 'Minor traffic delays' : 'Heavy traffic alerts');\n return `${status} • ${mins} min`;\n]]]\n",
"label": "Typical commute: ~25 min",
"icon": "[[[ return parseFloat(entity.state) < 35 ? 'mdi:check-circle' : (parseFloat(entity.state) < 45 ? 'mdi:alert-circle' : 'mdi:alert-octagon'); ]]]",
"styles": {
"card": [
{
"border-radius": "0px 0px 16px 16px"
},
{
"padding": "12px 16px"
}
],
"grid": [
{
"grid-template-areas": "\"i n\""
},
{
"grid-template-columns": "40px 1fr"
}
],
"icon": [
{
"color": "[[[ return parseFloat(entity.state) < 35 ? '#2ecc71' : (parseFloat(entity.state) < 45 ? '#e67e22' : '#e74c3c'); ]]]"
}
],
"name": [
{
"font-weight": "500"
},
{
"justify-self": "start"
}
],
"label": [
{
"font-size": "12px"
},
{
"color": "gray"
},
{
"justify-self": "start"
}
]
}
}
]
},
{
"type": "heading",
"heading": "Control"
}
]
},
{
"cards": [
{
"type": "heading",
"heading": "Schedule"
},
{
"title": "Today's Calendar",
"entities": [
"calendar.family_calendar"
],
"default_view": "agenda",
"first_day_of_week": 0,
"week_days": [
0,
1,
2,
3,
4,
5,
6
],
"week_start_hour": 0,
"week_end_hour": 23,
"lock_schedule_hours": false,
"hide_the_past": false,
"past_event_mode": "none",
"disable_swipe_controls": false,
"show_all_events_month": false,
"show_all_details_month": false,
"hide_empty_days": true,
"agenda_compact_events": false,
"shorten_event_times": false,
"time_zone": "",
"display_full_weekday_names": false,
"compact_width": false,
"show_current_time_bar": false,
"show_event_location": false,
"use_short_location": false,
"event_location_font_size": 9,
"background_opacity": 0,
"header_background_opacity": 0,
"event_calendar_friendly_name": false,
"event_title_prefix": "none",
"combine_style": "bars",
"combine_background": "primary",
"event_color_mode": "classic",
"event_neutral_background": "#F8F3E9",
"event_tint_opacity": 80,
"event_color_bar_width": 18,
"day_badges": [],
"day_badge_layout_week": "inline",
"hide_calendars": true,
"hide_header": true,
"hide_year": false,
"hide_controls": false,
"hide_navigation_buttons": false,
"hide_add_event_button": false,
"hide_view_selector": false,
"hide_dark_mode_toggle": false,
"show_dashboard_nav_button": false,
"header_dashboard_path": null,
"header_weather_sensor": "",
"calendar_person_entities": {},
"default_hidden_calendars": [],
"color_scheme": "auto",
"enable_event_management": true,
"event_modal_size": "medium",
"type": "custom:daylight-calendar-card",
"rolling_days": 7,
"compact_height": true,
"card_mod": {
"style": "ha-card {\n border-radius: 18px !important;\n padding: 16px !important;\n background-color: transparent !important;\n box-shadow: none !important;\n border: none !important;\n}\n.daylight-header-title {\n font-weight: bold !important;\n font-size: 18px !important;\n color: var(--primary-text-color) !important;\n}\n.daylight-event-wrapper {\n background: #ffffff !important;\n border: 1px solid #e1e4e8 !important;\n border-radius: 14px !important;\n margin-bottom: 12px !important;\n padding: 14px 16px !important;\n box-shadow: 0 4px 12px rgba(0,0,0,0.02) !important;\n border-left: 5px solid var(--daylight-event-color, #2196f3) !important;\n}\n.daylight-event-title {\n font-weight: bold !important;\n font-size: 15px !important;\n color: #2c3e50 !important;\n}\n.daylight-event-time {\n font-size: 12px !important;\n color: #95a5a6 !important;\n margin-bottom: 2px !important;\n}\n"
},
"hide_calendar_names": false
}
]
},
{
"type": "grid",
"cards": [
{
"show_state": false,
"show_name": false,
"camera_view": "auto",
"fit_mode": "cover",
"type": "picture-entity",
"entity": "camera.album_slideshow_google_screensaver",
"camera_image": "camera.album_slideshow_google_screensaver"
}
]
}
]
}
]
}
}
}
+141
View File
@@ -0,0 +1,141 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace.network_cellphone",
"data": {
"config": {
"views": [
{
"type": "sections",
"sections": [
{
"type": "grid",
"cards": [
{
"type": "heading",
"icon": "mdi:fridge",
"heading": "Network Status",
"heading_style": "title"
},
{
"type": "vertical-stack",
"cards": [
{
"type": "entities",
"show_header_toggle": false,
"state_color": true,
"entities": [
{
"entity": "sensor.gt_ax11000_connected_devices",
"name": "Active Clients",
"icon": "mdi:laptop"
},
{
"entity": "sensor.gt_ax11000_wan_download_speed",
"name": "Internet Download",
"icon": "mdi:arrow-down"
},
{
"entity": "sensor.gt_ax11000_wan_upload_speed",
"name": "Internet Upload",
"icon": "mdi:arrow-up"
},
{
"entity": "sensor.gt_ax11000_wired_download_speed",
"name": "Wired D/L",
"icon": "mdi:lan-connect"
},
{
"entity": "sensor.gt_ax11000_wired_upload_speed",
"name": "Wired U/L",
"icon": "mdi:lan-pending"
}
],
"card_mod": {
"style": "ha-card {\n background: #0d0e12 !important;\n border-left: 1px solid #1a1d26 !important;\n border-right: 1px solid #1a1d26 !important;\n border-top: none !important;\n border-bottom: none !important;\n border-radius: 0px !important;\n box-shadow: none !important;\n --primary-text-color: #f1f5f9;\n --secondary-text-color: #94a3b8;\n --paper-item-icon-color: #3b82f6;\n}\n.card-content {\n padding: 16px 20px 4px 20px !important;\n}\n"
}
}
]
},
{
"type": "custom:layout-card",
"layout_type": "grid",
"grid_options": {
"columns": "full",
"rows": 3
},
"layout": {
"grid-template-columns": "3fr 1fr",
"grid-gap": "12px"
},
"cards": [
{
"type": "custom:mini-graph-card",
"entities": [
{
"entity": "sensor.gt_ax11000_wan_download_speed",
"name": "Download",
"color": null
},
{
"entity": "sensor.gt_ax11000_wan_upload_speed",
"name": "Upload",
"color": null
}
],
"hours_to_show": 6,
"points_per_hour": 6,
"line_width": 4,
"show": {
"name": false,
"icon": false,
"state": true,
"legend": true,
"labels": false
},
"card_mod": {
"style": "ha-card {\n background: #0d0e12 !important;\n border: 1px solid #1a1d26 !important;\n border-radius: 12px !important;\n padding: 12px !important;\n height: 100% !important;\n box-sizing: border-box;\n}\n.header {\n padding: 0 !important;\n}\n.states {\n font-size: 14px !important;\n font-weight: 600;\n}\n.states--secondary {\n font-size: 12px !important;\n}\n"
}
},
{
"type": "vertical-stack",
"cards": [
{
"type": "sensor",
"entity": "sensor.gt_ax11000_temperature_cpu",
"name": "CPU Temp",
"graph": "none",
"card_mod": {
"style": "ha-card {\n background: #0d0e12 !important;\n border: 1px solid #1a1d26 !important;\n border-radius: 12px !important;\n padding: 8px 12px !important;\n box-sizing: border-box;\n margin-bottom: 4px !important;\n}\n.name {\n font-size: 12px !important;\n color: #808080 !important;\n}\n.value {\n font-size: 16px !important;\n font-weight: 600 !important;\n}\n"
}
}
]
}
]
},
{
"type": "gauge",
"entity": "sensor.gt_ax11000_cpu",
"grid_options": {
"columns": 6,
"rows": "auto"
},
"name": "CPU Usage"
},
{
"type": "gauge",
"entity": "sensor.gt_ax11000_ram",
"grid_options": {
"columns": 6,
"rows": "auto"
},
"name": "RAM Ussage"
}
]
}
]
}
]
}
}
}
+90
View File
@@ -0,0 +1,90 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace_dashboards",
"data": {
"items": [
{
"id": "map",
"icon": "mdi:map",
"title": "Map",
"url_path": "map",
"require_admin": false,
"show_in_sidebar": false,
"mode": "storage"
},
{
"id": "dashboard_home",
"show_in_sidebar": true,
"title": "Home",
"require_admin": false,
"mode": "storage",
"url_path": "dashboard-home"
},
{
"id": "alarm_section",
"show_in_sidebar": true,
"title": "Alarm Section",
"require_admin": false,
"mode": "storage",
"url_path": "alarm-section"
},
{
"id": "dashboard_temp",
"show_in_sidebar": true,
"title": "Stats",
"require_admin": false,
"mode": "storage",
"url_path": "dashboard-temp"
},
{
"id": "kobo_dashboard",
"show_in_sidebar": false,
"title": "Kobo Dashboard",
"require_admin": false,
"mode": "storage",
"url_path": "kobo-dashboard"
},
{
"id": "morning_hub",
"show_in_sidebar": true,
"title": "Morning Hub",
"require_admin": false,
"mode": "storage",
"url_path": "morning-hub"
},
{
"id": "morning_ipad",
"show_in_sidebar": false,
"title": "Morning iPad",
"require_admin": false,
"mode": "storage",
"url_path": "morning-ipad"
},
{
"id": "network_cellphone",
"show_in_sidebar": true,
"title": "Network CellPhone",
"require_admin": false,
"mode": "storage",
"url_path": "network-cellphone"
},
{
"id": "in_meeting",
"show_in_sidebar": true,
"title": "In Meeting",
"require_admin": false,
"mode": "storage",
"url_path": "in-meeting"
},
{
"id": "dashboard_music",
"show_in_sidebar": true,
"title": "Music",
"require_admin": false,
"mode": "storage",
"url_path": "dashboard-music"
}
]
}
}
+304
View File
@@ -0,0 +1,304 @@
{
"version": 1,
"minor_version": 1,
"key": "lovelace_resources",
"data": {
"items": [
{
"id": "cc55565add58413bbcadba9f9cf8caba",
"url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375511",
"type": "module"
},
{
"id": "387073c1f6fe4a658c6ca7e185b1134c",
"url": "/hacsfiles/sunsynk-power-flow-card/sunsynk-power-flow-card.js?hacstag=613588535733",
"type": "module"
},
{
"id": "52ed5bcf36d24b36a1f1b3584ef7b64c",
"url": "/hacsfiles/power-flow-card-plus/power-flow-card-plus.js?hacstag=618081815037",
"type": "module"
},
{
"id": "6bde3dd4cdd0478d9e77c0d68d093cfd",
"url": "/hacsfiles/energy-sankey/energy-sankey.js?hacstag=875798863104",
"type": "module"
},
{
"id": "ee7be1b18ae84060a5d515640f6e5739",
"url": "/seatemperatures_frontend/sea-temperatures-card.js?v=3.1.0",
"type": "module"
},
{
"id": "53837b51a371459b95ffc0989ce615fb",
"url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749320",
"type": "module"
},
{
"id": "c86cd6c3d8eb451ebde1a41ad5a1dd33",
"url": "/hacsfiles/lovelace-auto-entities/auto-entities.js?hacstag=1677445841161",
"type": "module"
},
{
"id": "0b28e2d01d0d4033b2f0a339cc7d86c1",
"url": "/ha_washdata/ha-washdata-card.js?v=1781361396",
"type": "module"
},
{
"id": "8ce0dbd60e7641be90f2e08594d1db2e",
"url": "/hacsfiles/HA-Firemote/HA-Firemote.js?hacstag=536329656419",
"type": "module"
},
{
"id": "6ad5d0d7a4c9435a85476e49d22d4df4",
"url": "/hacsfiles/versatile-thermostat-ui-card/versatile-thermostat-ui-card.js?hacstag=714354847320",
"type": "module"
},
{
"id": "0476eb038c9a47a7b654a078b824a9d1",
"url": "/hacsfiles/scheduler-card/scheduler-card.js?hacstag=2862701574018",
"type": "module"
},
{
"id": "5182b0b7bbc94cd7956a7eaf1389ebd1",
"url": "/hacsfiles/lovelace-thermostat-pro-timeline/thermostat-pro-timeline.js?hacstag=1072526491300",
"type": "module"
},
{
"id": "193c54e0fb0947dabbae7a889f0b5f46",
"url": "/local/thermostat-pro-timeline.js?v=56675817588421",
"type": "module"
},
{
"id": "5447aa1e0b934b57b58b98751c3c3350",
"url": "/hacsfiles/homie-dashboard/homie-dashboard.js?hacstag=1221720415400",
"type": "module"
},
{
"id": "9a535448bf5645889b43fbdf481bb225",
"url": " /local/community/homie-dashboard/homie-dashboard.js",
"type": "module"
},
{
"id": "3996b960ca1a49cd81de517c5e1b658a",
"url": "/hacsfiles/sensor-bar-card-plus/sensor-bar-card-plus.js?hacstag=1183027678163",
"type": "module"
},
{
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
"url": "/taskmate/taskmate-attr-resolver.js?v=5.0.1",
"type": "module"
},
{
"id": "0796c92931dd458e81dc9c2677248274",
"url": "/taskmate/taskmate-localize.js?v=5.0.1",
"type": "module"
},
{
"id": "d2e281920ac7409583eafff1f1db69bd",
"url": "/taskmate/taskmate-design.js?v=5.0.1",
"type": "module"
},
{
"id": "9eae9632f0734480be5edd784b030673",
"url": "/taskmate/taskmate-badges-card.js?v=5.0.1",
"type": "module"
},
{
"id": "5df49cd8a4eb420497b5df22cb31362c",
"url": "/taskmate/taskmate-child-card.js?v=5.0.1",
"type": "module"
},
{
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
"url": "/taskmate/taskmate-rewards-card.js?v=5.0.1",
"type": "module"
},
{
"id": "787a96f9c30548708165a48457c99feb",
"url": "/taskmate/taskmate-approvals-card.js?v=5.0.1",
"type": "module"
},
{
"id": "84031597de424ad68e7c2bc66d06b72e",
"url": "/taskmate/taskmate-points-card.js?v=5.0.1",
"type": "module"
},
{
"id": "02613225844746b699bfbfac9642eca2",
"url": "/taskmate/taskmate-reorder-card.js?v=5.0.1",
"type": "module"
},
{
"id": "7a06a07e17ba4b31b712bc91b77360b4",
"url": "/taskmate/taskmate-overview-card.js?v=5.0.1",
"type": "module"
},
{
"id": "08b42b53b33b4fa88db932455cf8d5aa",
"url": "/taskmate/taskmate-activity-card.js?v=5.0.1",
"type": "module"
},
{
"id": "15907eff51784acdba88ff694b574509",
"url": "/taskmate/taskmate-streak-card.js?v=5.0.1",
"type": "module"
},
{
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
"url": "/taskmate/taskmate-weekly-card.js?v=5.0.1",
"type": "module"
},
{
"id": "289ce5943f5a4972a593697db4c96ece",
"url": "/taskmate/taskmate-graph-card.js?v=5.0.1",
"type": "module"
},
{
"id": "d2904acee4c74a398dcc7b996c23e7e7",
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.0.1",
"type": "module"
},
{
"id": "46d378736fd54a26be253f2dfb27202e",
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.0.1",
"type": "module"
},
{
"id": "db11ae36457842fa826c7991e1d9c6f4",
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.0.1",
"type": "module"
},
{
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
"url": "/taskmate/taskmate-penalties-card.js?v=5.0.1",
"type": "module"
},
{
"id": "1959e1629de2422daa8adaf87069c288",
"url": "/taskmate/taskmate-bonuses-card.js?v=5.0.1",
"type": "module"
},
{
"id": "2548524b269c4959bc7e0e967df943af",
"url": "/taskmate/taskmate-points-display-card.js?v=5.0.1",
"type": "module"
},
{
"id": "36ada49368db4187a283fbbbef45454f",
"url": "/taskmate/taskmate-calendar-card.js?v=5.0.1",
"type": "module"
},
{
"id": "67c32687acd2497b90030ef141f4b6db",
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.0.1",
"type": "module"
},
{
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
"url": "/taskmate/taskmate-family-goal-card.js?v=5.0.1",
"type": "module"
},
{
"id": "e92ef0caff41454e9f49ea966ed99e41",
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=178921037471",
"type": "module"
},
{
"id": "4d1a17aa96b74064af713fc5e9334862",
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680152",
"type": "module"
},
{
"id": "d3c3e144a91247ce8ac524814737d7f8",
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164651",
"type": "module"
},
{
"id": "0922e4ba1c7c4f11969d0257689af5a3",
"url": "/hacsfiles/button-card/button-card.js?hacstag=146194325701",
"type": "module"
},
{
"id": "e9bc54c181334248b39e0f8874998041",
"url": "/hacsfiles/atomic-calendar-revive/atomic-calendar-revive.js?hacstag=2465497471031",
"type": "module"
},
{
"id": "c9b6518d876d437a9671194eb1919026",
"url": "/hacsfiles/daylight-calendar-card/skylight-calendar-card.js?hacstag=1143954763470",
"type": "module"
},
{
"id": "5612169a8e664ae5bef0aa3803a076db",
"url": "/hacsfiles/platinum-weather-card/platinum-weather-card.js?hacstag=488086721105",
"type": "module"
},
{
"id": "449ff43140654e4bbe2c7b2d361dfacc",
"url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019294",
"type": "module"
},
{
"id": "dab9cfff6e834886bcf4d5c7c9968c9e",
"url": "/webrtc/webrtc-camera.js?v=v3.6.1",
"type": "module"
},
{
"id": "94255369635043dfbbf598581e56569d",
"url": "/hacsfiles/apexcharts-card/apexcharts-card.js?hacstag=331701152223",
"type": "module"
},
{
"id": "1251007d3e5a44a7bbced052f41b5f9c",
"url": "/hacsfiles/mini-graph-card/mini-graph-card-bundle.js?hacstag=1512800620130",
"type": "module"
},
{
"id": "0893e5cfeaf1486f9c44cef25048230a",
"url": "/hacsfiles/lovelace-layout-card/layout-card.js?hacstag=156434866247",
"type": "module"
},
{
"id": "8713ea9c008e4270bb28085eed5be959",
"url": "/hacsfiles/Statistics-Graph-Chart-Card/statistics-graph-chart-card.js?hacstag=1034932856330",
"type": "module"
},
{
"id": "6c3f7d87683d4473b5dc8fe8177535f6",
"url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807410",
"type": "module"
},
{
"id": "9a17b155e3fb42949f2b2f64e24c42d5",
"url": "/hacsfiles/mass-player-card/mass-player-card.js?hacstag=1043277514280",
"type": "module"
},
{
"id": "e635fcf579d24ceb97758cc125ed5664",
"url": "/my_music_library/my-music-library-card.js?v=3.10.4",
"type": "module"
},
{
"id": "15981f60b53f4d5886272ea59de3861b",
"url": "/hacsfiles/uptime-card/uptime-card.js?hacstag=3505098670160",
"type": "module"
},
{
"id": "a3fc4486bc2c4edab1f38af295b4ef3f",
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.5.0",
"type": "module"
},
{
"id": "73dbd2de03de4530b451a55bfe6c46cf",
"url": "/taskmate/taskmate-routine-card.js?v=5.0.1",
"type": "module"
},
{
"id": "a2f2c6ca7b354458a8d4910d453f0684",
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
"type": "module"
}
]
}
}
+38
View File
@@ -2,6 +2,44 @@
You are working directly within a Home Assistant installation. Your working directory is `/homeassistant`, which is the live Home Assistant configuration directory.
## Repo-Specific Facts
### Version and Structure
- **HA Version**: 2026.7.3 (see `.HA_VERSION`)
- **Config entry point**: `configuration.yaml` — flat config, no `!include_dir` for automations (all in `automations.yaml`)
- **No packages directory** — all automations live in a single `automations.yaml` (~2960 lines)
- **Prettier** is configured (`.prettierrc.yaml`) and auto-formats YAML on save; it enforces 2-space indent and double quotes but NOT block-style enforcement or boolean normalization
### Blink Camera Dual-Entity Setup (CRITICAL)
Blink cameras are connected via **two Homebridge plugins** plus the native HA Blink integration. This creates **two sets of camera entities per physical camera**:
| Physical Camera | HomeKit (Homebridge) | BlinkCameras (Homebridge) | Native HA Blink |
|----------------|---------------------|--------------------------|-----------------|
| Front | `camera.front` | `camera.blink_front` | — |
| Backyard | `camera.backyard` | `camera.blink_backyard` | — |
| Shed | `camera.backyard_shed` | `camera.blink_backyard_shed` | — |
| Tree | `camera.backyard_tree` | `camera.blink_backyard_tree` | — |
| Garage Side | — | `camera.blink_garage_side` | — |
| Thermopump | — | `camera.blink_thermopump` | — |
**Refresh switches** (`switch.*_refresh_snapshot`) come from the BlinkCameras plugin. When writing automations that take snapshots, **match the camera entity to the plugin that owns the refresh switch** — using a HomeKit camera with a BlinkCameras refresh switch produces stale images. Use `camera.blink_*` entities for snapshot automation.
The `camera.front``switch.front_refresh_snapshot` pairing is the known working combination.
### Critical Device Rules
- **`switch.dragon_egg`** (living room smart plug) — **NEVER turn off after 10 PM**. Always check time before any service call targeting this entity.
### Custom Integrations (33 installed)
Key ones: `hacs`, `alexa_media`, `hilo`, `powercalc`, `versatile_thermostat`, `webrtc`, `nest_protect`, `midea_ac_lan`, `spook`, `watchman`, `alarmo`, `cync_lights`, `wyzeapi`, `asusrouter`, `trueNas`.
### Zigbee2MQTT
Config at `zigbee2mqtt/configuration.yaml`. Use `zigporter` for device management (cascade renames, mesh visualization). `hab` can list devices but does NOT cascade references.
### Reload vs Restart
- After editing `automations.yaml`: call `automation.reload` service (no restart needed)
- After editing `configuration.yaml`: requires HA restart
- After editing `scripts.yaml`: call `script.reload` service
## CRITICAL: User Consent and Scope Rules
You MUST follow these rules strictly:
+2478 -2477
View File
File diff suppressed because it is too large Load Diff
-426
View File
@@ -1,426 +0,0 @@
"""The AI Agent HA integration."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.components.frontend import async_register_built_in_panel
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .agent import AiAgentHaAgent
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Config schema - this integration only supports config entries
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
# Define service schema to accept a custom prompt
SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("prompt"): cv.string,
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the AI Agent HA component."""
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entries to new version."""
_LOGGER.debug("Migrating config entry from version %s", entry.version)
if entry.version == 1:
# No migration needed for version 1
return True
# Future migrations would go here
# if entry.version < 2:
# # Migrate from version 1 to 2
# new_data = dict(entry.data)
# # Add migration logic here
# hass.config_entries.async_update_entry(entry, data=new_data, version=2)
_LOGGER.info("Migration to version %s successful", entry.version)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up AI Agent HA from a config entry."""
try:
# Handle version compatibility
if not hasattr(entry, "version") or entry.version != 1:
_LOGGER.warning(
"Config entry has version %s, expected 1. Attempting compatibility mode.",
getattr(entry, "version", "unknown"),
)
# Convert ConfigEntry to dict and ensure all required keys exist
config_data = dict(entry.data)
# Ensure backward compatibility - check for required keys
if "ai_provider" not in config_data:
_LOGGER.error(
"Config entry missing required 'ai_provider' key. Entry data: %s",
config_data,
)
raise ConfigEntryNotReady("Config entry missing required 'ai_provider' key")
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {"agents": {}, "configs": {}}
provider = config_data["ai_provider"]
# Validate provider
if provider not in [
"llama",
"openai",
"gemini",
"openrouter",
"anthropic",
"alter",
"zai",
"local_ollama",
"openai_compatible",
]:
_LOGGER.error("Unknown AI provider: %s", provider)
raise ConfigEntryNotReady(f"Unknown AI provider: {provider}")
# Store config for this provider
hass.data[DOMAIN]["configs"][provider] = config_data
# Create agent for this provider
_LOGGER.debug(
"Creating AI agent for provider %s with config: %s",
provider,
{
k: v
for k, v in config_data.items()
if k
not in [
"llama_token",
"openai_token",
"gemini_token",
"openrouter_token",
"anthropic_token",
"zai_token",
]
},
)
hass.data[DOMAIN]["agents"][provider] = AiAgentHaAgent(hass, config_data)
_LOGGER.info("Successfully set up AI Agent HA for provider: %s", provider)
except KeyError as err:
_LOGGER.error("Missing required configuration key: %s", err)
raise ConfigEntryNotReady(f"Missing required configuration key: {err}")
except Exception as err:
_LOGGER.exception("Unexpected error setting up AI Agent HA")
raise ConfigEntryNotReady(f"Error setting up AI Agent HA: {err}")
# Modify the query service handler to use the correct provider
async def async_handle_query(call):
"""Handle the query service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
result = {"error": "No AI agents configured"}
hass.bus.async_fire("ai_agent_ha_response", result)
return
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
result = {"error": "No AI agents configured"}
hass.bus.async_fire("ai_agent_ha_response", result)
return
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
result = await agent.process_query(
call.data.get("prompt", ""),
provider=provider,
debug=call.data.get("debug", False),
)
hass.bus.async_fire("ai_agent_ha_response", result)
except Exception as e:
_LOGGER.error(f"Error processing query: {e}")
result = {"error": str(e)}
hass.bus.async_fire("ai_agent_ha_response", result)
async def async_handle_create_automation(call):
"""Handle the create_automation service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
result = await agent.create_automation(call.data.get("automation", {}))
return result
except Exception as e:
_LOGGER.error(f"Error creating automation: {e}")
return {"error": str(e)}
async def async_handle_save_prompt_history(call):
"""Handle the save_prompt_history service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
user_id = call.context.user_id if call.context.user_id else "default"
result = await agent.save_user_prompt_history(
user_id, call.data.get("history", [])
)
return result
except Exception as e:
_LOGGER.error(f"Error saving prompt history: {e}")
return {"error": str(e)}
async def async_handle_load_prompt_history(call):
"""Handle the load_prompt_history service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
user_id = call.context.user_id if call.context.user_id else "default"
result = await agent.load_user_prompt_history(user_id)
_LOGGER.debug("Load prompt history result: %s", result)
return result
except Exception as e:
_LOGGER.error(f"Error loading prompt history: {e}")
return {"error": str(e)}
async def async_handle_create_dashboard(call):
"""Handle the create_dashboard service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
# Parse dashboard config if it's a string
dashboard_config = call.data.get("dashboard_config", {})
if isinstance(dashboard_config, str):
try:
import json
dashboard_config = json.loads(dashboard_config)
except json.JSONDecodeError as e:
_LOGGER.error(f"Invalid JSON in dashboard_config: {e}")
return {"error": f"Invalid JSON in dashboard_config: {e}"}
result = await agent.create_dashboard(dashboard_config)
return result
except Exception as e:
_LOGGER.error(f"Error creating dashboard: {e}")
return {"error": str(e)}
async def async_handle_update_dashboard(call):
"""Handle the update_dashboard service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
# Parse dashboard config if it's a string
dashboard_config = call.data.get("dashboard_config", {})
if isinstance(dashboard_config, str):
try:
import json
dashboard_config = json.loads(dashboard_config)
except json.JSONDecodeError as e:
_LOGGER.error(f"Invalid JSON in dashboard_config: {e}")
return {"error": f"Invalid JSON in dashboard_config: {e}"}
dashboard_url = call.data.get("dashboard_url", "")
if not dashboard_url:
return {"error": "Dashboard URL is required"}
result = await agent.update_dashboard(dashboard_url, dashboard_config)
return result
except Exception as e:
_LOGGER.error(f"Error updating dashboard: {e}")
return {"error": str(e)}
# Register services
hass.services.async_register(DOMAIN, "query", async_handle_query)
hass.services.async_register(
DOMAIN, "create_automation", async_handle_create_automation
)
hass.services.async_register(
DOMAIN, "save_prompt_history", async_handle_save_prompt_history
)
hass.services.async_register(
DOMAIN, "load_prompt_history", async_handle_load_prompt_history
)
hass.services.async_register(
DOMAIN, "create_dashboard", async_handle_create_dashboard
)
hass.services.async_register(
DOMAIN, "update_dashboard", async_handle_update_dashboard
)
# Register static path for frontend
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/frontend/ai_agent_ha",
hass.config.path("custom_components/ai_agent_ha/frontend"),
False,
)
]
)
# Panel registration with proper error handling
panel_name = "ai_agent_ha"
try:
if await _panel_exists(hass, panel_name):
_LOGGER.debug("AI Agent HA panel already exists, skipping registration")
return True
_LOGGER.debug("Registering AI Agent HA panel")
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title="AI Agent HA",
sidebar_icon="mdi:robot",
frontend_url_path=panel_name,
require_admin=False,
config={
"_panel_custom": {
"name": "ai_agent_ha-panel",
"module_url": "/frontend/ai_agent_ha/ai_agent_ha-panel.js",
"embed_iframe": False,
}
},
)
_LOGGER.debug("AI Agent HA panel registered successfully")
except Exception as e:
_LOGGER.warning("Panel registration error: %s", str(e))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if await _panel_exists(hass, "ai_agent_ha"):
try:
from homeassistant.components.frontend import async_remove_panel
async_remove_panel(hass, "ai_agent_ha")
_LOGGER.debug("AI Agent HA panel removed successfully")
except Exception as e:
_LOGGER.debug("Error removing panel: %s", str(e))
# Remove services
hass.services.async_remove(DOMAIN, "query")
hass.services.async_remove(DOMAIN, "create_automation")
hass.services.async_remove(DOMAIN, "save_prompt_history")
hass.services.async_remove(DOMAIN, "load_prompt_history")
hass.services.async_remove(DOMAIN, "create_dashboard")
hass.services.async_remove(DOMAIN, "update_dashboard")
# Remove data
if DOMAIN in hass.data:
hass.data.pop(DOMAIN)
return True
async def _panel_exists(hass: HomeAssistant, panel_name: str) -> bool:
"""Check if a panel already exists."""
try:
return hasattr(hass.data, "frontend_panels") and panel_name in hass.data.get(
"frontend_panels", {}
)
except Exception as e:
_LOGGER.debug("Error checking panel existence: %s", str(e))
return False
File diff suppressed because it is too large Load Diff
@@ -1,917 +0,0 @@
"""Config flow for AI Agent HA integration."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
TextSelector,
TextSelectorConfig,
)
from .agent import (
fetch_gemini_models,
fetch_openai_compatible_models,
fetch_openai_models,
)
from .const import (
CONF_LOCAL_OLLAMA_URL,
CONF_OPENAI_BASE_URL,
CONF_OPENAI_COMPATIBLE_URL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
PROVIDERS = {
"llama": "Llama",
"openai": "OpenAI",
"gemini": "Google Gemini",
"openrouter": "OpenRouter",
"anthropic": "Anthropic (Claude)",
"alter": "Alter",
"zai": "z.ai",
"local_ollama": "Local Ollama",
"openai_compatible": "Local OpenAI-Compatible (e.g. LM Studio, vLLM)",
}
TOKEN_FIELD_NAMES = {
"llama": "llama_token",
"openai": "openai_token",
"gemini": "gemini_token",
"openrouter": "openrouter_token",
"anthropic": "anthropic_token",
"alter": "alter_token",
"zai": "zai_token",
"zai_endpoint": "zai_endpoint",
"local_ollama": CONF_LOCAL_OLLAMA_URL, # For local Ollama models, we use URL instead of token
"openai_compatible": CONF_OPENAI_COMPATIBLE_URL, # For OpenAI-compatible endpoints
}
TOKEN_LABELS = {
"llama": "Llama API Token",
"openai": "OpenAI API Key",
"gemini": "Google Gemini API Key",
"openrouter": "OpenRouter API Key",
"anthropic": "Anthropic API Key",
"alter": "Alter API Key",
"zai": "z.ai API Key",
"zai_endpoint": "z.ai API Endpoint Type",
"local_ollama": "Local Ollama API URL (e.g., http://localhost:11434/api/generate)",
"openai_compatible": "OpenAI-Compatible URL (e.g., http://example.com/v1/ or http://localhost:8080/v1/). Must end with /v1/",
}
DEFAULT_MODELS = {
"llama": "Llama-4-Maverick-17B-128E-Instruct-FP8",
"openai": "gpt-5",
"gemini": "gemini-2.5-flash",
"openrouter": "openai/gpt-4o",
"anthropic": "claude-sonnet-4-5-20250929",
"alter": "", # User enters custom model
"zai": "glm-4.7", # Z.ai's latest flagship model
"local_ollama": "llama3.2", # Updated to use llama3.2 as default for local Ollama
"openai_compatible": "", # User enters custom model for OpenAI-compatible endpoint
}
AVAILABLE_MODELS = {
"openai": [
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"o3",
"o3-mini",
"o4-mini",
"o1",
"o1-preview",
"o1-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
],
"gemini": [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
"gemini-2.5-flash-preview",
"gemini-2.5-pro-preview",
],
"openrouter": [
"openai/gpt-4o",
"openai/gpt-4-turbo",
"openai/gpt-3.5-turbo",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3-sonnet",
"anthropic/claude-3-haiku",
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.2-90b-instruct",
"google/gemini-pro",
"mistralai/mixtral-8x7b-instruct",
"deepseek/deepseek-r1",
],
"anthropic": [
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
"llama": [
"Llama-4-Maverick-17B-128E-Instruct-FP8",
"Llama-3.1-70B-Instruct",
"Llama-3.1-8B-Instruct",
"Llama-3.2-90B-Instruct",
],
# Alter - user enters custom model name only
"alter": [
"Custom...",
],
# z.ai - available models
"zai": [
"glm-4.7",
"glm-4.6",
"glm-4.5",
"glm-4.5-air",
"glm-4.5-x",
"glm-4.5-airx",
"glm-4.5-flash",
"glm-4-32b-0414-128k",
"Custom...",
],
# For local Ollama models, provide common models with llama3.2 as the default
"local_ollama": [
"llama3.2",
"llama3",
"llama3.1",
"mistral",
"mixtral",
"deepseek-coder",
"Custom...",
],
# For OpenAI-compatible endpoints, user should specify their model
"openai_compatible": [
"Custom...",
],
}
DEFAULT_PROVIDER = "openai"
class AiAgentHaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg,misc]
"""Handle a config flow for AI Agent HA."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
try:
return AiAgentHaOptionsFlowHandler()
except Exception as e:
_LOGGER.error("Error creating options flow: %s", e)
return None
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
# Check if this provider is already configured
await self.async_set_unique_id(f"ai_agent_ha_{user_input['ai_provider']}")
self._abort_if_unique_id_configured()
self.config_data = {"ai_provider": user_input["ai_provider"]}
return await self.async_step_configure()
# Show provider selection form
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("ai_provider"): SelectSelector(
SelectSelectorConfig(
options=[
{"value": k, "label": v} for k, v in PROVIDERS.items()
]
)
),
}
),
)
async def async_step_configure(self, user_input=None):
"""Handle the configuration step for the selected provider."""
errors = {}
provider = self.config_data["ai_provider"]
token_field = TOKEN_FIELD_NAMES[provider]
token_label = TOKEN_LABELS[provider]
default_model = DEFAULT_MODELS[provider]
# For Alter provider, default to "Custom..." for the dropdown since model is user-provided
dropdown_default = "Custom..." if provider == "alter" else default_model
available_models = AVAILABLE_MODELS.get(provider, [default_model])
if user_input is not None:
try:
# Validate the token
token_value = user_input.get(token_field)
if not token_value:
errors[token_field] = "required"
raise InvalidApiKey
# Store the configuration data
self.config_data[token_field] = token_value
# For z.ai, store endpoint type
if provider == "zai":
endpoint_type = user_input.get("zai_endpoint", "general")
self.config_data["zai_endpoint"] = endpoint_type
# For OpenAI, store Base URL (defaults to official endpoint if unchanged)
if provider == "openai":
base_url = (user_input.get(CONF_OPENAI_BASE_URL) or "").strip()
self.config_data[CONF_OPENAI_BASE_URL] = (
base_url or "https://api.openai.com/v1"
)
# For OpenAI, move to next step to select model from dynamic list
return await self.async_step_configure_openai_models()
# For OpenAI-Compatible, store Base URL + optional API key, then move on
if provider == "openai_compatible":
base_url = (
user_input.get(CONF_OPENAI_COMPATIBLE_URL) or ""
).strip()
self.config_data[CONF_OPENAI_COMPATIBLE_URL] = base_url
api_key = (
user_input.get("openai_compatible_api_key") or ""
).strip()
self.config_data["openai_compatible_api_key"] = api_key
# Move to next step to select model from dynamic list
return await self.async_step_configure_openai_compatible_models()
# Add model configuration if provided
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
_LOGGER.debug(
f"Config flow - Provider: {provider}, Selected model: {selected_model}, Custom model: {custom_model}"
)
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
# Use custom model if provided and not empty
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
# Use selected model if it's not the "Custom..." option
self.config_data["models"][provider] = selected_model
else:
# For local_ollama, openai_compatible, alter, and zai providers, allow empty model name
if provider in (
"local_ollama",
"openai_compatible",
"alter",
"zai",
):
self.config_data["models"][provider] = ""
else:
# Fallback to default model for other providers
self.config_data["models"][provider] = default_model
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except InvalidApiKey:
errors["base"] = "invalid_api_key"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if provider == "zai":
# For z.ai provider, we need token, endpoint type, and optional model name
model_options = AVAILABLE_MODELS.get("zai", ["Custom..."])
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional("zai_endpoint", default="general"): SelectSelector(
SelectSelectorConfig(
options=[
{"value": "general", "label": "General Purpose"},
{"value": "coding", "label": "Coding (3× usage, 1/7 cost)"},
]
)
),
vol.Optional("model", default="glm-4.7"): SelectSelector(
SelectSelectorConfig(options=model_options)
),
vol.Optional("custom_model"): TextSelector(
TextSelectorConfig(type="text")
),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
if provider == "local_ollama":
# For local_ollama provider, we need both URL and optional model name
schema_dict = {
vol.Required(CONF_LOCAL_OLLAMA_URL): TextSelector(
TextSelectorConfig(type="text")
),
}
# Add model selection
model_options = AVAILABLE_MODELS.get("local_ollama", ["Custom..."])
schema_dict[vol.Optional("model", default="Custom...")] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model")] = TextSelector(
TextSelectorConfig(type="text")
)
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local Ollama API URL", # nosec B105 - UI label string shown next to the URL field, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai_compatible":
# For openai_compatible provider, we need base URL + optional API key.
# Many local endpoints (LM Studio, vLLM) need no key; gateways like
# Open WebUI or LiteLLM require one. Models are fetched in the next step.
schema_dict = {
vol.Required(CONF_OPENAI_COMPATIBLE_URL): TextSelector(
TextSelectorConfig(type="text")
),
vol.Optional("openai_compatible_api_key", default=""): TextSelector(
TextSelectorConfig(type="password")
),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local OpenAI-Compatible URL", # nosec B105 - UI label for config form, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai":
# For OpenAI provider, first step: API Key + Base URL
# Model selection happens in the next step after we fetch available models
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional(
CONF_OPENAI_BASE_URL,
default="https://api.openai.com/v1",
): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
# Build schema for other providers
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
}
# Add model selection if available
if available_models:
# For Gemini, fetch models dynamically
if provider == "gemini":
token_value = self.config_data.get("gemini_token")
model_list = await fetch_gemini_models(token_value)
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
model_options = model_list
else:
# Add predefined models + custom option (avoid duplicating "Custom...")
if "Custom..." in available_models:
model_options = available_models
else:
model_options = available_models + ["Custom..."]
schema_dict[vol.Optional("model", default=dropdown_default)] = (
SelectSelector(SelectSelectorConfig(options=model_options))
)
schema_dict[vol.Optional("custom_model")] = TextSelector(
TextSelectorConfig(type="text")
)
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
async def async_step_configure_openai_models(self, user_input=None):
"""Handle the OpenAI model selection step with dynamic model list."""
errors = {}
provider = "openai"
token = self.config_data.get("openai_token")
base_url = self.config_data.get(
CONF_OPENAI_BASE_URL, "https://api.openai.com/v1"
)
# Fetch available models dynamically
model_list = await fetch_openai_models(base_url, token)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
if user_input is not None:
try:
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
self.config_data["models"][provider] = selected_model
else:
self.config_data["models"][provider] = ""
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception in OpenAI model selection")
errors["base"] = "unknown"
schema_dict = {
vol.Optional("model", default="Custom..."): SelectSelector(
SelectSelectorConfig(options=model_list)
),
vol.Optional("custom_model"): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_openai_models",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"provider": PROVIDERS[provider],
},
)
async def async_step_configure_openai_compatible_models(self, user_input=None):
"""Handle the OpenAI-Compatible model selection step with dynamic model list."""
errors = {}
provider = "openai_compatible"
base_url = self.config_data.get(CONF_OPENAI_COMPATIBLE_URL, "")
api_key = self.config_data.get("openai_compatible_api_key") or ""
# Fetch available models dynamically if the endpoint supports it
model_list = await fetch_openai_compatible_models(base_url, api_key or None)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
if user_input is not None:
try:
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
self.config_data["models"][provider] = selected_model
else:
self.config_data["models"][provider] = ""
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unexpected exception in OpenAI-Compatible model selection"
)
errors["base"] = "unknown"
schema_dict = {
vol.Optional("model", default="Custom..."): SelectSelector(
SelectSelectorConfig(options=model_list)
),
vol.Optional("custom_model"): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_openai_compatible_models",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"provider": PROVIDERS[provider],
},
)
class InvalidApiKey(HomeAssistantError):
"""Error to indicate there is an invalid API key."""
class AiAgentHaOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for AI Agent HA."""
def __init__(self):
"""Initialize options flow."""
self.options_data = {}
async def async_step_init(self, user_input=None):
"""Handle the initial options step - provider selection."""
current_provider = self.config_entry.data.get("ai_provider", DEFAULT_PROVIDER)
if user_input is not None:
# Store selected provider and move to configure step
self.options_data = {
"ai_provider": user_input["ai_provider"],
"current_provider": current_provider,
}
return await self.async_step_configure_options()
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
"ai_provider", default=current_provider
): SelectSelector(
SelectSelectorConfig(
options=[
{"value": k, "label": v} for k, v in PROVIDERS.items()
]
)
),
}
),
description_placeholders={"current_provider": PROVIDERS[current_provider]},
)
async def async_step_configure_options(self, user_input=None):
"""Handle the configuration step for the selected provider in options."""
errors = {}
provider = self.options_data["ai_provider"]
current_provider = self.options_data["current_provider"]
token_field = TOKEN_FIELD_NAMES[provider]
token_label = TOKEN_LABELS[provider]
# Get current configuration
current_models = self.config_entry.data.get("models", {})
current_model = current_models.get(provider, DEFAULT_MODELS[provider])
# For Alter provider, if model is empty, default to "Custom..." for the dropdown
if provider == "alter" and not current_model:
current_model = "Custom..."
current_token = self.config_entry.data.get(token_field, "")
available_models = AVAILABLE_MODELS.get(provider, [DEFAULT_MODELS[provider]])
# Use current token if provider hasn't changed, otherwise empty
display_token = current_token if provider == current_provider else ""
# Determine if current model is a custom model (not in available models list)
# and prepare model dropdown and custom model field defaults
model_options = available_models
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
# Check if current_model is a custom model (not in the available models)
# Remove "Custom..." from the check since it's the selector option, not a real model
available_models_without_custom = [
m for m in available_models if m != "Custom..."
]
is_custom_model = (
current_model
and current_model not in available_models_without_custom
and current_model != "Custom..."
)
if is_custom_model:
# Current model is a custom model - show "Custom..." in dropdown and populate custom field
model_default = "Custom..."
custom_model_default = current_model
else:
# Current model is a standard model or empty
model_default = current_model if current_model else "Custom..."
custom_model_default = ""
if user_input is not None:
try:
token_value = user_input.get(token_field)
if not token_value:
errors[token_field] = "required"
else:
# Prepare the updated configuration
updated_data = dict(self.config_entry.data)
updated_data["ai_provider"] = provider
updated_data[token_field] = token_value
# Update model configuration
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# For zai, update endpoint type
if provider == "zai":
endpoint_type = user_input.get("zai_endpoint", "general")
updated_data["zai_endpoint"] = endpoint_type
# For OpenAI, update Base URL (default to official if blank)
if provider == "openai":
base_url = (user_input.get(CONF_OPENAI_BASE_URL) or "").strip()
updated_data[CONF_OPENAI_BASE_URL] = (
base_url or "https://api.openai.com/v1"
)
# For OpenAI-Compatible, update the optional API key
if provider == "openai_compatible":
updated_data["openai_compatible_api_key"] = (
user_input.get("openai_compatible_api_key") or ""
).strip()
# Initialize models dict if it doesn't exist
if "models" not in updated_data:
updated_data["models"] = {}
if custom_model and custom_model.strip():
# Use custom model if provided and not empty
updated_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
# Use selected model if it's not the "Custom..." option
updated_data["models"][provider] = selected_model
else:
# For local_ollama, openai_compatible, alter, and zai providers, allow empty model name
if provider in (
"local_ollama",
"openai_compatible",
"alter",
"zai",
):
updated_data["models"][provider] = ""
else:
# Ensure we keep the current model or use default for other providers
if provider not in updated_data["models"]:
updated_data["models"][provider] = DEFAULT_MODELS[
provider
]
_LOGGER.debug(
f"Options flow - Final model config for {provider}: {updated_data['models'].get(provider)}"
)
# Update the config entry
self.hass.config_entries.async_update_entry(
self.config_entry, data=updated_data
)
return self.async_create_entry(title="", data={})
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception in options flow")
errors["base"] = "unknown"
# Build schema for the selected provider in options
if provider == "zai":
current_endpoint = self.config_entry.data.get("zai_endpoint", "general")
model_options = AVAILABLE_MODELS.get("zai", ["glm-4.7"])
# Ensure "Custom..." is in model options
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional("zai_endpoint", default=current_endpoint): SelectSelector(
SelectSelectorConfig(
options=[
{"value": "general", "label": "General Purpose"},
{"value": "coding", "label": "Coding (3× usage, 1/7 cost)"},
]
)
),
vol.Optional("model", default=model_default): SelectSelector(
SelectSelectorConfig(options=model_options)
),
vol.Optional(
"custom_model", default=custom_model_default
): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
if provider == "local_ollama":
# For local_ollama provider, we need both URL and optional model name
current_url = self.config_entry.data.get(CONF_LOCAL_OLLAMA_URL, "")
schema_dict = {
vol.Required(CONF_LOCAL_OLLAMA_URL, default=current_url): TextSelector(
TextSelectorConfig(type="text")
),
}
# Add model selection
model_options = AVAILABLE_MODELS.get("local_ollama", ["Custom..."])
# Ensure "Custom..." is in model options
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local Ollama API URL", # nosec B105 - UI label string shown next to the URL field, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai_compatible":
# For openai_compatible provider, we need URL + optional API key + model
current_url = self.config_entry.data.get(CONF_OPENAI_COMPATIBLE_URL, "")
current_api_key = (
self.config_entry.data.get("openai_compatible_api_key") or ""
)
# Fetch available models dynamically if the endpoint supports it
model_list = await fetch_openai_compatible_models(
current_url, current_api_key or None
)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
schema_dict = {
vol.Required(
CONF_OPENAI_COMPATIBLE_URL, default=current_url
): TextSelector(TextSelectorConfig(type="text")),
vol.Optional(
"openai_compatible_api_key", default=current_api_key
): TextSelector(TextSelectorConfig(type="password")),
}
# Add model selection with dynamic list
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_list)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local OpenAI-Compatible URL", # nosec B105 - UI label for config form, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai":
# For OpenAI provider, we need token and optional Base URL
# Pre-fill with official endpoint if not set
current_base_url = (
self.config_entry.data.get(CONF_OPENAI_BASE_URL)
or "https://api.openai.com/v1"
)
# Fetch available models dynamically
current_token = self.config_entry.data.get("openai_token", "")
model_list = await fetch_openai_models(current_base_url, current_token)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional(
CONF_OPENAI_BASE_URL, default=current_base_url
): TextSelector(TextSelectorConfig(type="text")),
}
# Add model selection with dynamic list
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_list)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
# Build schema for other providers
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
}
# Add model selection if available
if available_models:
# For Gemini, fetch models dynamically
if provider == "gemini":
current_token = self.config_entry.data.get("gemini_token", "")
model_list = await fetch_gemini_models(current_token)
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
model_options = model_list
# model_options already has "Custom..." added above for other providers
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
-40
View File
@@ -1,40 +0,0 @@
"""Constants for the AI Agent HA integration."""
DOMAIN = "ai_agent_ha"
CONF_API_KEY = "api_key"
CONF_WEATHER_ENTITY = "weather_entity"
# AI Provider configuration keys
CONF_LLAMA_TOKEN = "llama_token" # nosec B105
CONF_OPENAI_TOKEN = "openai_token" # nosec B105
CONF_OPENAI_BASE_URL = (
"openai_base_url" # nosec B105 - configuration key, not a credential
)
CONF_GEMINI_TOKEN = "gemini_token" # nosec B105
CONF_OPENROUTER_TOKEN = "openrouter_token" # nosec B105
CONF_ANTHROPIC_TOKEN = "anthropic_token" # nosec B105
CONF_ALTER_TOKEN = "alter_token" # nosec B105
CONF_ZAI_TOKEN = "zai_token" # nosec B105
CONF_LOCAL_OLLAMA_URL = "local_ollama_url"
CONF_LOCAL_OLLAMA_MODEL = "local_ollama_model"
CONF_OPENAI_COMPATIBLE_URL = "openai_compatible_url"
CONF_LOCAL_URL = "local_url" # legacy alias for local_ollama_url
# Available AI providers
AI_PROVIDERS = [
"llama",
"openai",
"gemini",
"openrouter",
"anthropic",
"alter",
"zai",
"local_ollama",
"openai_compatible",
]
# AI Provider constants
CONF_MODELS = "models"
# Supported AI providers
DEFAULT_AI_PROVIDER = "openai"
@@ -1,390 +0,0 @@
"""
Dashboard templates and examples for AI agent to use when creating dashboards.
"""
# Basic dashboard templates for different use cases
DASHBOARD_TEMPLATES = {
"simple_lights": {
"title": "Lights Dashboard",
"url_path": "lights",
"icon": "mdi:lightbulb",
"show_in_sidebar": True,
"views": [
{
"title": "All Lights",
"cards": [
{
"type": "entities",
"title": "Living Room Lights",
"entities": [], # To be filled with actual light entities
},
{
"type": "light",
"entity": "", # To be filled with main light entity
},
],
}
],
},
"security": {
"title": "Security Dashboard",
"url_path": "security",
"icon": "mdi:security",
"show_in_sidebar": True,
"views": [
{
"title": "Security Overview",
"cards": [
{
"type": "entities",
"title": "Sensors",
"entities": [], # To be filled with sensor entities
},
{
"type": "entities",
"title": "Cameras",
"entities": [], # To be filled with camera entities
},
{
"type": "alarm-panel",
"entity": "", # To be filled with alarm panel entity
},
],
}
],
},
"climate": {
"title": "Climate Control",
"url_path": "climate",
"icon": "mdi:thermometer",
"show_in_sidebar": True,
"views": [
{
"title": "Temperature & Humidity",
"cards": [
{
"type": "thermostat",
"entity": "", # To be filled with climate.* thermostat entity (optional)
},
{
"type": "history-graph",
"title": "Temperature History",
"entities": [], # To be filled with temperature sensor entities
"hours_to_show": 24,
},
{
"type": "entities",
"title": "Temperature Sensors",
"entities": [], # To be filled with temperature sensor entities (device_class: temperature)
},
{
"type": "history-graph",
"title": "Humidity History",
"entities": [], # To be filled with humidity sensor entities
"hours_to_show": 24,
},
{
"type": "entities",
"title": "Humidity Sensors",
"entities": [], # To be filled with humidity sensor entities (device_class: humidity)
},
{
"type": "weather-forecast",
"entity": "", # To be filled with weather entity (optional)
},
],
}
],
},
"media": {
"title": "Media Center",
"url_path": "media",
"icon": "mdi:play",
"show_in_sidebar": True,
"views": [
{
"title": "Media Players",
"cards": [
{
"type": "media-control",
"entity": "", # To be filled with media player entity
},
{
"type": "entities",
"title": "All Media Players",
"entities": [], # To be filled with media player entities
},
],
}
],
},
"energy": {
"title": "Energy Monitoring",
"url_path": "energy",
"icon": "mdi:lightning-bolt",
"show_in_sidebar": True,
"views": [
{
"title": "Energy Usage",
"cards": [
{"type": "energy-distribution", "title": "Energy Distribution"},
{
"type": "entities",
"title": "Power Sensors",
"entities": [], # To be filled with power sensor entities
},
{
"type": "history-graph",
"title": "Power Usage",
"entities": [], # To be filled with power entities
},
],
}
],
},
}
# Card type examples and their typical use cases
CARD_EXAMPLES = {
"entities": {
"description": "Shows a list of entities with their states",
"example": {
"type": "entities",
"title": "Living Room",
"entities": [
"light.living_room_main",
"switch.living_room_fan",
"sensor.living_room_temperature",
],
},
},
"glance": {
"description": "Shows entities in a compact grid format",
"example": {
"type": "glance",
"title": "Quick Overview",
"entities": [
"binary_sensor.front_door",
"binary_sensor.back_door",
"binary_sensor.garage_door",
],
},
},
"thermostat": {
"description": "Controls and displays thermostat information",
"example": {"type": "thermostat", "entity": "climate.main_thermostat"},
},
"weather-forecast": {
"description": "Shows weather information and forecast",
"example": {
"type": "weather-forecast",
"entity": "weather.home",
"name": "Weather",
},
},
"media-control": {
"description": "Controls media players with full interface",
"example": {"type": "media-control", "entity": "media_player.living_room_tv"},
},
"light": {
"description": "Dedicated light control card",
"example": {"type": "light", "entity": "light.living_room_main"},
},
"alarm-panel": {
"description": "Security alarm panel interface",
"example": {"type": "alarm-panel", "entity": "alarm_control_panel.home_alarm"},
},
"picture-entity": {
"description": "Shows entity state with a background image",
"example": {
"type": "picture-entity",
"entity": "light.living_room",
"image": "/local/living_room.jpg",
},
},
"history-graph": {
"description": "Shows historical data as a graph",
"example": {
"type": "history-graph",
"title": "Temperature History",
"entities": ["sensor.temperature_indoor", "sensor.temperature_outdoor"],
"hours_to_show": 24,
},
},
"gauge": {
"description": "Shows a single entity value as a gauge",
"example": {
"type": "gauge",
"entity": "sensor.cpu_temperature",
"min": 0,
"max": 100,
"name": "CPU Temperature",
},
},
}
# Predefined color schemes and icons
COMMON_ICONS = {
"lights": "mdi:lightbulb",
"security": "mdi:security",
"climate": "mdi:thermometer",
"energy": "mdi:lightning-bolt",
"media": "mdi:play",
"kitchen": "mdi:chef-hat",
"bedroom": "mdi:bed",
"bathroom": "mdi:shower",
"living_room": "mdi:sofa",
"garage": "mdi:garage",
"garden": "mdi:flower",
"office": "mdi:desk",
"basement": "mdi:stairs-down",
"attic": "mdi:stairs-up",
}
def get_template_for_entities(entities, dashboard_type="general"):
"""Generate a dashboard template based on available entities."""
template = {
"title": f"{dashboard_type.title()} Dashboard",
"url_path": dashboard_type.lower().replace(" ", "-"),
"icon": COMMON_ICONS.get(dashboard_type, "mdi:view-dashboard"),
"show_in_sidebar": True,
"views": [],
}
# Group entities by domain and by device_class for sensors
entity_groups = {}
sensor_by_device_class = {}
for entity in entities:
if isinstance(entity, dict) and "entity_id" in entity:
entity_id = entity["entity_id"]
# Get device_class from attributes if available
device_class = entity.get("attributes", {}).get("device_class")
else:
entity_id = str(entity)
device_class = None
domain = entity_id.split(".")[0]
if domain not in entity_groups:
entity_groups[domain] = []
entity_groups[domain].append(entity_id)
# Categorize sensors by device_class
if domain == "sensor" and device_class:
if device_class not in sensor_by_device_class:
sensor_by_device_class[device_class] = []
sensor_by_device_class[device_class].append(entity_id)
# Create view with cards for each domain
view_cards = []
# Lights
if "light" in entity_groups:
view_cards.append(
{"type": "entities", "title": "Lights", "entities": entity_groups["light"]}
)
# Climate - handle both climate entities and temperature/humidity sensors
if "climate" in entity_groups:
for climate_entity in entity_groups["climate"]:
view_cards.append({"type": "thermostat", "entity": climate_entity})
# Temperature sensors - create history graphs for climate dashboards
if "temperature" in sensor_by_device_class:
temp_sensors = sensor_by_device_class["temperature"]
# Add history graph for temperature visualization
view_cards.append(
{
"type": "history-graph",
"title": "Temperature History",
"entities": temp_sensors,
"hours_to_show": 24,
}
)
# Add entity card for current values
view_cards.append(
{
"type": "entities",
"title": "Temperature Sensors",
"entities": temp_sensors,
}
)
# Humidity sensors - create gauge cards for climate dashboards
if "humidity" in sensor_by_device_class:
humidity_sensors = sensor_by_device_class["humidity"]
# Add history graph for humidity visualization
view_cards.append(
{
"type": "history-graph",
"title": "Humidity History",
"entities": humidity_sensors,
"hours_to_show": 24,
}
)
# Add entity card for current values
view_cards.append(
{
"type": "entities",
"title": "Humidity Sensors",
"entities": humidity_sensors,
}
)
# Media players
if "media_player" in entity_groups:
for media_entity in entity_groups["media_player"]:
view_cards.append({"type": "media-control", "entity": media_entity})
# Security entities
if "binary_sensor" in entity_groups:
view_cards.append(
{
"type": "entities",
"title": "Sensors",
"entities": entity_groups["binary_sensor"],
}
)
if "alarm_control_panel" in entity_groups:
for alarm_entity in entity_groups["alarm_control_panel"]:
view_cards.append({"type": "alarm-panel", "entity": alarm_entity})
# Other Sensors (not temperature or humidity)
if "sensor" in entity_groups:
# Filter out temperature and humidity sensors that we already handled
other_sensors = [
s
for s in entity_groups["sensor"]
if s not in sensor_by_device_class.get("temperature", [])
and s not in sensor_by_device_class.get("humidity", [])
]
if other_sensors:
view_cards.append(
{
"type": "entities",
"title": "Other Sensors",
"entities": other_sensors[:10], # Limit to first 10
}
)
# Switches
if "switch" in entity_groups:
view_cards.append(
{
"type": "entities",
"title": "Switches",
"entities": entity_groups["switch"],
}
)
# Weather
if "weather" in entity_groups:
view_cards.append(
{"type": "weather-forecast", "entity": entity_groups["weather"][0]}
)
template["views"] = [{"title": "Overview", "cards": view_cards}]
return template
@@ -1,8 +0,0 @@
panel_custom:
- name: AI Agent HA
sidebar_title: AI Agent HA
sidebar_icon: mdi:robot
url_path: ai_agent_ha
module_url: /local/ai_agent_ha/frontend/ai_agent_ha-panel.js
trust_external: true
require_admin: false
File diff suppressed because it is too large Load Diff
@@ -1,24 +0,0 @@
{
"domain": "ai_agent_ha",
"name": "AI Agent HA",
"after_dependencies": [
"history",
"recorder",
"lovelace"
],
"codeowners": [
"@sbenodiz"
],
"config_flow": true,
"dependencies": [
"http"
],
"documentation": "https://github.com/sbenodiz/ai_agent_ha",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/sbenodiz/ai_agent_ha/issues",
"requirements": [
"aiohttp>=3.8.0"
],
"version": "1.14.1"
}
@@ -1,98 +0,0 @@
query:
name: "Query AI Agent with Home Assistant context"
description: "Run a custom AI prompt against your Home Assistant state dump."
fields:
prompt:
description: "The question or instruction to send to AI model."
example: "Turn on all the lights in the living room"
debug:
description: "Include a debug trace of the HA↔AI conversation (true/false)."
example: true
default: false
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
create_dashboard:
name: "Create Dashboard via AI Agent"
description: "Create a new Home Assistant dashboard using AI assistance."
fields:
dashboard_config:
description: "The dashboard configuration as a JSON object."
example: '{"title": "My Dashboard", "url_path": "my-dashboard", "views": []}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
create_automation:
name: "Create Automation via AI Agent"
description: "Create a new Home Assistant automation using AI assistance."
fields:
automation:
description: "The automation configuration as a JSON object."
example: '{"alias": "Turn off lights at 9 PM", "trigger": [{"platform": "time", "at": "21:00:00"}], "action": [{"service": "light.turn_off", "target": {"entity_id": "light.living_room"}}]}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
update_dashboard:
name: "Update Dashboard via AI Agent"
description: "Update an existing Home Assistant dashboard using AI assistance."
fields:
dashboard_url:
description: "The URL path of dashboard to update."
example: "my-dashboard"
dashboard_config:
description: "The updated dashboard configuration as a JSON object."
example: '{"title": "Updated Dashboard", "views": []}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
@@ -1,91 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Choose AI Provider",
"description": "Select your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure": {
"title": "Configure {provider}",
"description": "Enter your {token_label} and optionally select a model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a predefined model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key format",
"unknown": "Unknown error occurred",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"anthropic_token": "Anthropic API key is required",
"alter_token": "Alter API key is required"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Provider Settings",
"description": "Current provider: {current_provider}. Select a provider to configure",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure_options": {
"title": "Configure {provider}",
"description": "Update your {token_label} and model settings",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
}
}
}
@@ -1,81 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Agent d'IA HA - Seleccionar proveïdor",
"description": "Tria el teu proveïdor d'IA",
"data": {
"ai_provider": "Proveïdor d'IA"
},
"data_description": {
"ai_provider": "Selecciona el teu proveïdor d'IA preferit"
}
},
"configure": {
"title": "Agent d'IA HA - Configurar {provider}",
"description": "Configura les teves credencials d'API i el model de {provider}",
"data": {
"llama_token": "Token de l'API de Llama",
"openai_token": "Clau de l'API d'OpenAI",
"gemini_token": "Clau de l'API de Google Gemini",
"openrouter_token": "Clau de l'API d'OpenRouter",
"model": "Model (Opcional)",
"custom_model": "Nom del model personalitzat (Opcional)"
},
"data_description": {
"llama_token": "Introdueix el teu token de l'API de Llama",
"openai_token": "Introdueix la teva clau de l'API d'OpenAI",
"gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
"openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
"model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
"custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
}
}
},
"error": {
"invalid_api_key": "Clau o token d'API no vàlid",
"llama_token": "Es requereix el token de l'API de Llama",
"openai_token": "Es requereix la clau de l'API d'OpenAI",
"gemini_token": "Es requereix la clau de l'API de Google Gemini",
"openrouter_token": "Es requereix la clau de l'API d'OpenRouter",
"unknown": "S'ha produït un error inesperat"
},
"abort": {
"already_configured": "L'Agent d'IA HA ja està configurat"
}
},
"options": {
"step": {
"init": {
"title": "Agent d'IA HA - Seleccionar proveïdor",
"description": "Tria el teu proveïdor d'IA (Actual: {current_provider})",
"data": {
"ai_provider": "Proveïdor d'IA"
},
"data_description": {
"ai_provider": "Selecciona el teu proveïdor d'IA preferit"
}
},
"configure_options": {
"title": "Agent d'IA HA - Configurar {provider}",
"description": "Actualitza les teves credencials d'API i el model de {provider}",
"data": {
"llama_token": "Token de l'API de Llama",
"openai_token": "Clau de l'API d'OpenAI",
"gemini_token": "Clau de l'API de Google Gemini",
"openrouter_token": "Clau de l'API d'OpenRouter",
"model": "Model (Opcional)",
"custom_model": "Nom del model personalitzat (Opcional)"
},
"data_description": {
"llama_token": "Introdueix el teu token de l'API de Llama",
"openai_token": "Introdueix la teva clau de l'API d'OpenAI",
"gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
"openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
"model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
"custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
}
}
}
}
}
@@ -1,86 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "KI-Anbieter wählen",
"description": "Wähle deinen KI-Anbieter aus",
"data": {
"ai_provider": "KI-Anbieter"
},
"data_description": {
"ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
}
},
"configure": {
"title": "{provider} konfigurieren",
"description": "Gib deinen {token_label} ein und wähle optional ein Model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Gib deinen Llama API Token ein",
"openai_token": "Gib deinen OpenAI API Key ein",
"gemini_token": "Gib deinen Google Gemini API Key ein",
"openrouter_token": "Gib deinen OpenRouter API Key ein",
"anthropic_token": "Gib deinen Anthropic API Key ein",
"model": "Wähle ein vordefiniertes Model oder 'Custom...' zum manuellen Eintrag",
"custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
}
}
},
"error": {
"invalid_api_key": "Ungültiges API-Key-Format",
"unknown": "Unbekannter Fehler aufgetreten",
"llama_token": "Llama API Token ist erforderlich",
"openai_token": "OpenAI API Key ist erforderlich",
"gemini_token": "Google Gemini API Key ist erforderlich",
"openrouter_token": "OpenRouter API Key ist erforderlich",
"anthropic_token": "Anthropic API Key ist erforderlich"
},
"abort": {
"already_configured": "AI Agent HA ist bereits konfiguriert"
}
},
"options": {
"step": {
"init": {
"title": "Einstellungen für KI-Anbieter",
"description": "Aktueller Anbieter: {current_provider}. Wähle einen Anbieter zur Konfiguration",
"data": {
"ai_provider": "KI-Anbieter"
},
"data_description": {
"ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
}
},
"configure_options": {
"title": "{provider} konfigurieren",
"description": "Aktualisiere deinen {token_label} und Model-Einstellungen",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Gib deinen Llama API Token ein",
"openai_token": "Gib deinen OpenAI API Key ein",
"gemini_token": "Gib deinen Google Gemini API Key ein",
"openrouter_token": "Gib deinen OpenRouter API Key ein",
"anthropic_token": "Gib deinen Anthropic API Key ein",
"model": "Wähle ein Model oder 'Custom...' zum manuellen Eintrag",
"custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
}
}
}
}
}
@@ -1,91 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Choose AI Provider",
"description": "Select your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure": {
"title": "Configure {provider}",
"description": "Enter your {token_label} and optionally select a model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a predefined model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key format",
"unknown": "Unknown error occurred",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"anthropic_token": "Anthropic API key is required",
"alter_token": "Alter API key is required"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Provider Settings",
"description": "Current provider: {current_provider}. Select a provider to configure",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure_options": {
"title": "Configure {provider}",
"description": "Update your {token_label} and model settings",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
}
}
}
@@ -1,81 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "AI Agent HA - Select Provider",
"description": "Choose your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Select your preferred AI provider"
}
},
"configure": {
"title": "AI Agent HA - Configure {provider}",
"description": "Configure your {provider} API credentials and model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model Name (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"model": "Choose a predefined model or select 'Custom...' to use a custom model",
"custom_model": "Enter a custom model name (overrides the dropdown selection above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key or token",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Agent HA - Select Provider",
"description": "Choose your AI provider (Current: {current_provider})",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Select your preferred AI provider"
}
},
"configure_options": {
"title": "AI Agent HA - Configure {provider}",
"description": "Update your {provider} API credentials and model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model Name (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"model": "Choose a predefined model or select 'Custom...' to use a custom model",
"custom_model": "Enter a custom model name (overrides the dropdown selection above)"
}
}
}
}
}
@@ -1,81 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Agente de IA HA - Seleccionar proveedor",
"description": "Elige tu proveedor de IA",
"data": {
"ai_provider": "Proveedor de IA"
},
"data_description": {
"ai_provider": "Selecciona tu proveedor de IA preferido"
}
},
"configure": {
"title": "Agente de IA HA - Configurar {provider}",
"description": "Configura tus credenciales de API y modelo de {provider}",
"data": {
"llama_token": "Token de la API de Llama",
"openai_token": "Clave de la API de OpenAI",
"gemini_token": "Clave de la API de Google Gemini",
"openrouter_token": "Clave de la API de OpenRouter",
"model": "Modelo (Opcional)",
"custom_model": "Nombre de modelo personalizado (Opcional)"
},
"data_description": {
"llama_token": "Introduce tu token de la API de Llama",
"openai_token": "Introduce tu clave de la API de OpenAI",
"gemini_token": "Introduce tu clave de la API de Google Gemini",
"openrouter_token": "Introduce tu clave de la API de OpenRouter",
"model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
"custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
}
}
},
"error": {
"invalid_api_key": "Clave o token de API no válido",
"llama_token": "Se requiere el token de la API de Llama",
"openai_token": "Se requiere la clave de la API de OpenAI",
"gemini_token": "Se requiere la clave de la API de Google Gemini",
"openrouter_token": "Se requiere la clave de la API de OpenRouter",
"unknown": "Ha ocurrido un error inesperado"
},
"abort": {
"already_configured": "El Agente de IA HA ya está configurado"
}
},
"options": {
"step": {
"init": {
"title": "Agente de IA HA - Seleccionar proveedor",
"description": "Elige tu proveedor de IA (Actual: {current_provider})",
"data": {
"ai_provider": "Proveedor de IA"
},
"data_description": {
"ai_provider": "Selecciona tu proveedor de IA preferido"
}
},
"configure_options": {
"title": "Agente de IA HA - Configurar {provider}",
"description": "Actualiza tus credenciales de API y modelo de {provider}",
"data": {
"llama_token": "Token de la API de Llama",
"openai_token": "Clave de la API de OpenAI",
"gemini_token": "Clave de la API de Google Gemini",
"openrouter_token": "Clave de la API de OpenRouter",
"model": "Modelo (Opcional)",
"custom_model": "Nombre de modelo personalizado (Opcional)"
},
"data_description": {
"llama_token": "Introduce tu token de la API de Llama",
"openai_token": "Introduce tu clave de la API de OpenAI",
"gemini_token": "Introduce tu clave de la API de Google Gemini",
"openrouter_token": "Introduce tu clave de la API de OpenRouter",
"model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
"custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
}
}
}
}
}
@@ -54,6 +54,19 @@ from .const import (
DEFAULT_ICLOUD_IMAGE_SIZE,
ICLOUD_IMAGE_FULL,
ICLOUD_IMAGE_PREVIEW,
CONF_SYNOLOGY_URL,
CONF_SYNOLOGY_USERNAME,
CONF_SYNOLOGY_PASSWORD,
CONF_SYNOLOGY_DEVICE_ID,
CONF_SYNOLOGY_SPACE,
CONF_SYNOLOGY_ALBUM_ID,
CONF_SYNOLOGY_IMAGE_SIZE,
DEFAULT_SYNOLOGY_IMAGE_SIZE,
SYNOLOGY_SPACE_PERSONAL,
SYNOLOGY_SPACE_SHARED,
SYNOLOGY_IMAGE_SMALL,
SYNOLOGY_IMAGE_MEDIUM,
SYNOLOGY_IMAGE_LARGE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
@@ -61,6 +74,7 @@ from .const import (
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
PROVIDER_ICLOUD,
PROVIDER_SYNOLOGY,
DEFAULT_RECURSIVE,
)
@@ -108,6 +122,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._pp_password: str | None = None
self._pp_albums: dict[str, str] = {}
self._pp_people: dict[str, str] = {}
# Synology flow state carried between steps.
self._syn_url: str | None = None
self._syn_username: str | None = None
self._syn_password: str | None = None
self._syn_device_id: str | None = None
self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
self._syn_albums: dict[str, str] = {}
@staticmethod
@callback
@@ -143,6 +164,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_photoprism()
if self._provider == PROVIDER_ICLOUD:
return await self.async_step_icloud()
if self._provider == PROVIDER_SYNOLOGY:
return await self.async_step_synology()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -153,6 +176,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
PROVIDER_ICLOUD: "iCloud Shared Album",
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
@@ -651,6 +675,140 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="icloud", data_schema=schema, errors=errors
)
async def async_step_synology(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect the Synology URL + credentials (and optional 2FA code)."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_SYNOLOGY_URL].strip()
username = user_input[CONF_SYNOLOGY_USERNAME].strip()
password = user_input.get(CONF_SYNOLOGY_PASSWORD) or ""
space = user_input.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
otp = (user_input.get("otp_code") or "").strip()
from . import synology as syn_api
client = syn_api.SynologyClient(
self.hass,
url,
username=username,
password=password,
space=space,
)
try:
await client.async_login(otp_code=otp or None)
albums = await client.async_list_albums()
except syn_api.SynologyOtpRequired:
errors["otp_code"] = "synology_otp_required"
except Exception: # noqa: BLE001 - any failure means bad URL/creds
errors["base"] = "synology_cannot_connect"
else:
self._syn_url = client.base_url
self._syn_username = username
self._syn_password = password
self._syn_space = space
# A trusted-device token is captured only on the OTP login;
# store it so future logins skip the 2FA prompt.
self._syn_device_id = client.captured_device_id
self._syn_albums = {
str(a["id"]): (a.get("name") or str(a["id"]))
for a in albums
if a.get("id") is not None
}
await client.async_logout()
return await self.async_step_synology_select()
schema = vol.Schema(
{
vol.Required(CONF_SYNOLOGY_URL): str,
vol.Required(CONF_SYNOLOGY_USERNAME): str,
vol.Required(CONF_SYNOLOGY_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Required(
CONF_SYNOLOGY_SPACE, default=SYNOLOGY_SPACE_PERSONAL
): vol.In(
{
SYNOLOGY_SPACE_PERSONAL: "Personal (My Photos)",
SYNOLOGY_SPACE_SHARED: "Shared Space",
}
),
vol.Optional("otp_code"): str,
}
)
return self.async_show_form(
step_id="synology", data_schema=schema, errors=errors
)
async def async_step_synology_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Pick a Synology album (or the whole space) and image size."""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
)
album_id = user_input.get("album")
if album_id in (None, "", "__all__") or album_id not in self._syn_albums:
album_id = ""
unique = (
f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
f"{self._syn_space}:{album_id or 'all'}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
data = {
CONF_PROVIDER: PROVIDER_SYNOLOGY,
CONF_SYNOLOGY_URL: self._syn_url,
CONF_SYNOLOGY_USERNAME: self._syn_username,
CONF_SYNOLOGY_PASSWORD: self._syn_password,
CONF_SYNOLOGY_SPACE: self._syn_space,
CONF_SYNOLOGY_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
}
if album_id:
data[CONF_SYNOLOGY_ALBUM_ID] = album_id
if self._syn_device_id:
data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id
return self.async_create_entry(title=name, data=data)
album_options = [
selector.SelectOptionDict(value="__all__", label="All photos in this space")
] + [
selector.SelectOptionDict(value=aid, label=aname)
for aid, aname in self._syn_albums.items()
]
schema = vol.Schema(
{
vol.Required(CONF_ALBUM_NAME): str,
vol.Optional("album", default="__all__"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
),
vol.Optional(
CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
): vol.In(
{
SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
SYNOLOGY_IMAGE_MEDIUM: "Medium",
SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
}
),
}
)
return self.async_show_form(
step_id="synology_select", data_schema=schema, errors=errors
)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
@@ -57,6 +57,34 @@ PROVIDER_MEDIA_SOURCE = "media_source"
PROVIDER_IMMICH = "immich"
PROVIDER_PHOTOPRISM = "photoprism"
PROVIDER_ICLOUD = "icloud"
PROVIDER_SYNOLOGY = "synology"
# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
# entry.cgi web API. The account password is stored so the coordinator can
# re-authenticate when the session id expires; accounts with 2FA are handled by
# capturing a trusted-device token during setup (see synology.py).
CONF_SYNOLOGY_URL = "synology_url"
CONF_SYNOLOGY_USERNAME = "synology_username"
CONF_SYNOLOGY_PASSWORD = "synology_password"
CONF_SYNOLOGY_DEVICE_ID = "synology_device_id"
CONF_SYNOLOGY_SPACE = "synology_space"
CONF_SYNOLOGY_ALBUM_ID = "synology_album_id"
CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size"
# Personal ("My Photos") vs shared ("Shared Space") library.
SYNOLOGY_SPACE_PERSONAL = "personal"
SYNOLOGY_SPACE_SHARED = "shared"
# Native Synology thumbnail sizes. ``xl`` is the largest (best for a slideshow).
SYNOLOGY_IMAGE_SMALL = "sm"
SYNOLOGY_IMAGE_MEDIUM = "m"
SYNOLOGY_IMAGE_LARGE = "xl"
SYNOLOGY_IMAGE_SIZE_OPTIONS = [
SYNOLOGY_IMAGE_SMALL,
SYNOLOGY_IMAGE_MEDIUM,
SYNOLOGY_IMAGE_LARGE,
]
DEFAULT_SYNOLOGY_IMAGE_SIZE = SYNOLOGY_IMAGE_LARGE
# iCloud Shared Album provider. The share token in the pasted link is the only
# credential; no account or password is involved.
@@ -44,6 +44,15 @@ from .const import (
CONF_ICLOUD_TOKEN,
CONF_ICLOUD_IMAGE_SIZE,
DEFAULT_ICLOUD_IMAGE_SIZE,
CONF_SYNOLOGY_URL,
CONF_SYNOLOGY_USERNAME,
CONF_SYNOLOGY_PASSWORD,
CONF_SYNOLOGY_DEVICE_ID,
CONF_SYNOLOGY_SPACE,
CONF_SYNOLOGY_ALBUM_ID,
CONF_SYNOLOGY_IMAGE_SIZE,
DEFAULT_SYNOLOGY_IMAGE_SIZE,
SYNOLOGY_SPACE_PERSONAL,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
@@ -52,6 +61,7 @@ from .const import (
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
PROVIDER_ICLOUD,
PROVIDER_SYNOLOGY,
)
from .store import SlideshowStore
@@ -939,6 +949,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_photoprism()
elif self.provider == PROVIDER_ICLOUD:
data = await self._update_icloud()
elif self.provider == PROVIDER_SYNOLOGY:
data = await self._update_synology()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -1512,6 +1524,87 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_synology(self) -> dict[str, Any]:
"""Fetch photos from a Synology Photos library via its web API.
Metadata (capture date, GPS, address, description) is returned inline
with each item, so every ``MediaItem`` is built fully here - there is
no background enrichment pass. Thumbnail URLs carry no SID; the session
cookie is stored on the coordinator and sent server-side by the camera
(like the Immich x-api-key), so the SID never reaches the browser.
"""
from . import synology as syn_api
url = self.entry.data.get(CONF_SYNOLOGY_URL)
username = self.entry.data.get(CONF_SYNOLOGY_USERNAME)
password = self.entry.data.get(CONF_SYNOLOGY_PASSWORD)
device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
size = self.entry.data.get(
CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
)
if not url or not username or not password:
raise UpdateFailed("Synology provider is missing URL or credentials")
client = syn_api.SynologyClient(
self.hass,
url,
username=username,
password=password,
device_id=device_id,
space=space,
)
try:
await client.async_login()
photos = await client.async_collect_assets(album_id or None)
except Exception as err:
raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
if not photos:
await client.async_logout()
raise UpdateFailed("No images found for the selected Synology source")
# Store the session cookie so the camera can fetch thumbnail bytes
# server-side. Do not log out: the SID must stay valid until the next
# refresh re-authenticates.
self.image_request_headers = dict(client.image_headers)
items: list[MediaItem] = []
for p in photos:
ref = syn_api.thumbnail_ref(p)
if not ref:
continue
unit_id, cache_key = ref
meta = syn_api.parse_photo_meta(p)
items.append(
MediaItem(
url=syn_api.build_thumbnail_url(
client.base_url, unit_id, cache_key, size, space
),
width=meta.get("width"),
height=meta.get("height"),
mime_type=None,
filename=p.get("filename"),
captured_at=meta.get("captured_at"),
byte_size=meta.get("byte_size"),
latitude=meta.get("latitude"),
longitude=meta.get("longitude"),
location=meta.get("location"),
description=meta.get("description"),
source_id=str(p.get("id")) if p.get("id") is not None else None,
exif_scanned=True,
)
)
if not items:
raise UpdateFailed("Could not resolve any Synology images")
return {
"title": self.entry.title,
"items": items,
}
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
from . import immich as immich_api
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.4.0"
"version": "1.5.0"
}
+23 -1
View File
@@ -84,6 +84,26 @@
"icloud_url": "Shared album link",
"icloud_image_size": "Image quality"
}
},
"synology": {
"title": "Synology Photos",
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
"data": {
"synology_url": "DSM URL",
"synology_username": "Username",
"synology_password": "Password",
"synology_space": "Library",
"otp_code": "2FA code (only if enabled)"
}
},
"synology_select": {
"title": "Synology source",
"description": "Pick an album or show the whole library, and choose the image quality.",
"data": {
"album_name": "Album name",
"album": "Album",
"synology_image_size": "Image quality"
}
}
},
"error": {
@@ -100,7 +120,9 @@
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
}
},
"options": {
@@ -84,6 +84,26 @@
"icloud_url": "Shared album link",
"icloud_image_size": "Image quality"
}
},
"synology": {
"title": "Synology Photos",
"description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
"data": {
"synology_url": "DSM URL",
"synology_username": "Username",
"synology_password": "Password",
"synology_space": "Library",
"otp_code": "2FA code (only if enabled)"
}
},
"synology_select": {
"title": "Synology source",
"description": "Pick an album or show the whole library, and choose the image quality.",
"data": {
"album_name": "Album name",
"album": "Album",
"synology_image_size": "Image quality"
}
}
},
"error": {
@@ -100,7 +120,9 @@
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
"icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
"synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
"synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
}
},
"options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.4.0";
const VERSION = "1.5.0";
const ANIMATED_TRANSITIONS = [
"fade",
+2 -1
View File
@@ -820,7 +820,8 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow):
if CONF_PASSWORD in user_input:
self.config[CONF_PASSWORD] = user_input[CONF_PASSWORD]
if CONF_URL in user_input:
self.config[CONF_URL] = user_input[CONF_URL]
# Remove accidental whitespace introduced by mobile keyboards/paste.
self.config[CONF_URL] = "".join(user_input[CONF_URL].split())
if CONF_PUBLIC_URL in user_input:
if not user_input[CONF_PUBLIC_URL].endswith("/"):
user_input[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] + "/"
+1 -1
View File
@@ -14,5 +14,5 @@
"wrapt>=1.14.0",
"dictor>=0.1.12,<0.2"
],
"version": "5.15.6"
"version": "5.15.7"
}
+1 -1
View File
@@ -664,7 +664,7 @@ class AlexaMediaNotificationSensor(SensorEntity):
next_item["snoozedToTime"] = None
return value
def _is_active_notification(self, item, now):
def _is_active_notification(self, item, _now):
"""Return whether a notification should be considered active."""
status = item[1].get("status")
if status == "ON":
@@ -3,32 +3,32 @@
"abort": {
"forgot_password": "La page de réinitialisation du mot de passe a été détectée. Cela résulte généralement de trop nombreuses tentatives de connexion échouées. Amazon peut exiger une action avant qu'une nouvelle connexion ne puisse être tentée.",
"login_failed": "Alexa Media Player n'a pas réussi à se connecter.",
"reauth_successful": "Alexa Media Player s'est ré-authentifié avec succès. Veuillez ignorer le message \"Abandonné\" de Home Assistant."
"reauth_successful": "Alexa Media Player s'est ré-authentifié avec succès. Veuillez ignorer le message « Abandonné » de Home Assistant."
},
"error": {
"2fa_key_invalid": "{otp_secret} n'est pas valide",
"connection_error": "Erreur de connexion ; vérifiez le réseau et réessayez",
"connection_error": "Erreur de connexion ; vérifiez le réseau et réessayez",
"identifier_exists": "L'adresse e-mail pour cette URL Alexa est déjà enregistrée",
"invalid_auth": "La connexion a échoué. Veuillez vérifier votre adresse e-mail, votre mot de passe et votre clé d'authentification.",
"invalid_credentials": "Identifiants invalides",
"invalid_url": "L'URL n'est pas valide: {message}",
"invalid_url": "L'URL n'est pas valide : {message}",
"oauth_error": "Impossible de terminer la connexion OAuth. Veuillez réessayer.",
"unable_to_connect_hass_url": "Impossible de se connecter à l'URL locale de Home Assistant. Veuillez vérifier l'URL sous Paramètres > Système > Réseau > URL de Home Assistant > Réseau local",
"unknown_error": "Erreur inconnue : {message}"
"unknown_error": "Erreur inconnue : {message}"
},
"step": {
"proxy_warning": {
"data": {
"proxy_warning": "Ignorer et continuer - Je comprends qu'aucune assistance pour les problèmes de connexion ne sera fournie si je contourne cet avertissement."
},
"description": "Le serveur Home Assistant ne peut pas se connecter à l'URL fournie : {hass_url}.\n> {error}\n\nPour résoudre ce problème, veuillez confirmer que votre navigateur peut atteindre {hass_url}. Ce champ provient de Paramètres > Système > Réseau > URL de Home Assistant.\n\nSi vous êtes **certain** que votre navigateur peut accéder à cette URL, vous pouvez ignorer cet avertissement.",
"description": "Le serveur Home Assistant ne peut pas se connecter à l'URL fournie : {hass_url}.\n> {error}\n\nPour résoudre ce problème, veuillez confirmer que votre navigateur peut atteindre {hass_url}. Ce champ provient de Paramètres > Système > Réseau > URL de Home Assistant.\n\nSi vous êtes **certain** que votre navigateur peut accéder à cette URL, vous pouvez ignorer cet avertissement.",
"title": "Alexa Media Player - Impossible de se connecter à l'URL de HA"
},
"totp_register": {
"data": {
"registered": "Oui, le code OTP a été vérifié"
},
"description": "**{email} - alexa.{url}**\nAvez-vous vérifié le code OTP dans la validation en deux étapes Amazon ?\n> Code OTP : {message}",
"description": "**{email} - alexa.{url}**\nAvez-vous vérifié le code OTP dans la validation en deux étapes Amazon ?\n> Code OTP : {message}",
"title": "Alexa Media Player - Confirmation OTP"
},
"user": {
@@ -46,10 +46,10 @@
"scan_interval": "Intervalle d'interrogation programmé (secondes)",
"securitycode": "Mot de passe à usage unique (OTP)",
"should_get_network": "Découvrir le réseau Alexa",
"url": "Domaine de la région Amazon (ex : amazon.fr)"
"url": "Domaine de la région Amazon (ex : amazon.fr)"
},
"data_description": {
"debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé. \n Non recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux. \n Assurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé.\nNon recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux.\nAssurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
}
}
@@ -58,7 +58,7 @@
"entity": {
"sensor": {
"air_quality": {
"name": "qualité de l'air"
"name": "Qualité de l'air"
},
"air_quality_carbon_monoxide": {
"name": "Monoxyde de carbone"
@@ -67,7 +67,7 @@
"name": "Humidité"
},
"air_quality_indoor_air_quality": {
"name": "qualité de l'air intérieur"
"name": "Qualité de l'air intérieur"
},
"air_quality_particulate_matter": {
"name": "Matières particulaires"
@@ -82,7 +82,7 @@
"name": "Prochain rappel"
},
"next_timer": {
"name": "La prochaine fois"
"name": "Prochain minuteur"
},
"temperature": {
"name": "Température"
@@ -117,11 +117,11 @@
"otp_secret": "Clé d'authentification à 52 caractères pour Amazon 2SV",
"public_url": "URL publique partagée avec les services externes hébergés",
"queue_delay": "Délai pour regrouper plusieurs commandes (secondes)",
"scan_interval": "Fréquence d'interrogation programmée (secondes)",
"scan_interval": "Intervalle d'interrogation programmé (secondes)",
"should_get_network": "Découvrir le réseau Alexa"
},
"data_description": {
"debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé. \n Non recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux. \n Assurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé.\nNon recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux.\nAssurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
},
"description": "* Champs obligatoires",
@@ -135,7 +135,7 @@
"fields": {
"email": {
"description": "Adresse e-mail du compte Alexa ou liste d'adresses (facultatif). Si vide, tous les comptes connus seront actualisés.",
"name": "Adresse email"
"name": "Adresse e-mail"
}
},
"name": "Activer la découverte du réseau"
@@ -155,7 +155,7 @@
"fields": {
"entity_id": {
"description": "Entité pour laquelle obtenir l'historique",
"name": "Sélectionner le lecteur multimédia:"
"name": "Sélectionner le lecteur multimédia :"
},
"entries": {
"description": "Nombre d'entrées à récupérer",
@@ -169,7 +169,7 @@
"fields": {
"entity_id": {
"description": "Entité sur laquelle restaurer le niveau de volume précédent",
"name": "Sélectionner le lecteur multimédia:"
"name": "Sélectionner le lecteur multimédia :"
}
},
"name": "Restaurer le volume précédent"
+2 -1
View File
@@ -1000,7 +1000,8 @@ def _is_path_allowed_for_read(
Allowed:
- Files directly in config dir: configuration.yaml, automations.yaml, etc.
- Files in allowed directories: www/, themes/, custom_templates/
- Files in allowed directories: www/, themes/, custom_templates/,
dashboards/, blueprints/ (blueprints is read-only issue #1965)
- Files matching patterns: <packages-folder>/*.yaml, custom_components/**/*.py
- User-configured extra directories (``extra_dirs``), granted read+write
(issue #1567)
+12 -3
View File
@@ -24,7 +24,7 @@ DOMAIN = "ha_mcp_tools"
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
COMPONENT_VERSION = "1.2.2"
COMPONENT_VERSION = "1.2.3"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
@@ -40,8 +40,17 @@ TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
TOOLS_ENTRY_LEGACY_TITLE = "HA MCP Tools"
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0"
# Allowed directories for file operations (relative to config dir)
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# Allowed directories for file operations (relative to config dir).
# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not
# ALLOWED_WRITE_DIRS, so ha_write_file / ha_delete_file reject it (raw blueprint
# reads are safe: community YAML, no secrets — issue #1965). This is the default
# allowlist, not an absolute guarantee: an admin who adds "blueprints" as a
# custom extra directory (issue #1567, see _current_extra_dirs) grants it
# read+write, since extra_dirs are honored on the write path too. Blueprint
# writes should instead go through ha_import_blueprint (which invokes the
# blueprint/save WS command internally). Prefer ha_get_blueprint for the parsed
# body; raw read is the escape hatch for the exact on-disk text.
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards", "blueprints"]
ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# NON-OVERRIDABLE deny floor for the user-configurable extra read/write

Some files were not shown because too many files have changed in this diff Show More