Added Alexa Music
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,9 @@
|
||||
{"timestamp": "2026-07-16T01:51:24.833520+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_boolean", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 265.3963565826416, "success": true, "error_message": null, "response_size_bytes": 2297, "user_context": null}
|
||||
{"timestamp": "2026-07-16T01:51:24.844401+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_select", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 234.4343662261963, "success": true, "error_message": null, "response_size_bytes": 472, "user_context": null}
|
||||
{"timestamp": "2026-07-16T01:51:24.849852+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_number", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 281.5418243408203, "success": true, "error_message": null, "response_size_bytes": 640, "user_context": null}
|
||||
{"timestamp": "2026-07-16T01:51:25.011117+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "timer", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 184.76414680480957, "success": true, "error_message": null, "response_size_bytes": 465, "user_context": null}
|
||||
{"timestamp": "2026-07-16T01:51:25.012519+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "counter", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 186.4025592803955, "success": true, "error_message": null, "response_size_bytes": 826, "user_context": null}
|
||||
{"timestamp": "2026-07-16T01:51:27.603591+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_boolean", "area_filter": null, "search_types": null, "limit": 10, "offset": 10, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 78.72748374938965, "success": true, "error_message": null, "response_size_bytes": 818, "user_context": null}
|
||||
{"timestamp": "2026-07-16T14:58:34.806520+00:00", "tool_name": "ha_list_floors_areas", "parameters": {"fields": null, "area_fields": null}, "execution_time_ms": 34.44170951843262, "success": true, "error_message": null, "response_size_bytes": 3757, "user_context": null}
|
||||
{"timestamp": "2026-07-16T15:17:46.049602+00:00", "tool_name": "ha_get_overview", "parameters": {"detail_level": "minimal", "domains": null, "limit": null, "offset": 0, "max_entities_per_domain": null, "include_state": null, "include_entity_id": null, "include_notifications": true, "include_dismissed_repairs": false, "fields": null}, "execution_time_ms": 1456.7010402679443, "success": true, "error_message": null, "response_size_bytes": 14815, "user_context": null}
|
||||
{"timestamp": "2026-07-16T15:20:28.060870+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "automation", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 113.4181022644043, "success": true, "error_message": null, "response_size_bytes": 2476, "user_context": null}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784135767.6085248}
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784294980.0427423}
|
||||
+219
-37
@@ -834,8 +834,6 @@
|
||||
- condition: template
|
||||
value_template: '{{ states(''sensor.google_travel_time_work'') | float(0) >
|
||||
35 }}'
|
||||
- condition: template
|
||||
value_template: '{{ states(''sensor.waze_travel_time'') | float(0) > 35 }}'
|
||||
actions:
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
@@ -846,8 +844,7 @@
|
||||
data:
|
||||
title: "\U0001F6A8 Commute Delay Alert!"
|
||||
message: "Traffic to Work is heavier than usual this morning. \n- Google Maps:
|
||||
{{ states('sensor.google_travel_time_work') }} mins.\n- Waze: {{ states('sensor.waze_to_work_travel_time')
|
||||
}} mins."
|
||||
{{ states('sensor.google_travel_time_work') }} mins."
|
||||
data:
|
||||
image: /local/commute_snapshot.jpg
|
||||
enabled: false
|
||||
@@ -855,8 +852,7 @@
|
||||
data:
|
||||
title: "\U0001F6A8 Commute Delay Alert!"
|
||||
message: "Traffic to Work is heavier than usual this morning. \n- Google Maps:
|
||||
{{ states('sensor.google_travel_time_work') }} mins. \n- Waze: {{ states('sensor.waze_to_work_travel_time')
|
||||
}} mins.\n"
|
||||
{{ states('sensor.google_travel_time_work') }} mins. \n"
|
||||
mode: single
|
||||
- *id001
|
||||
- *id001
|
||||
@@ -898,17 +894,14 @@
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.google_travel_time_work
|
||||
above: 35
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.waze_travel_time
|
||||
above: 35
|
||||
actions:
|
||||
- action: notify.notify
|
||||
data:
|
||||
title: "\U0001F697 Morning Commute"
|
||||
message: "Google: {{ states('sensor.google_travel_time_work') }} min\nWaze:
|
||||
{{ states('sensor.waze_to_work_travel_time') }} min\n{% set g = states('sensor.google_travel_time_work')
|
||||
| float(0) %} {% if g > 50 %} \U0001F6A8 Major traffic delays. {% elif g >
|
||||
40 %} ⚠️ Heavy traffic. {% else %} \U0001F7E1 Moderate traffic. {% endif %}"
|
||||
message: "Google: {{ states('sensor.google_travel_time_work') }} min\n\n{% set
|
||||
g = states('sensor.google_travel_time_work') | float(0) %} {% if g > 50 %}
|
||||
\U0001F6A8 Major traffic delays. {% elif g > 40 %} ⚠️ Heavy traffic. {% else
|
||||
%} \U0001F7E1 Moderate traffic. {% endif %}"
|
||||
mode: single
|
||||
- id: '1783162325964'
|
||||
alias: Motion Shed - AI Description
|
||||
@@ -972,11 +965,16 @@
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 3
|
||||
seconds: 6
|
||||
milliseconds: 0
|
||||
- action: homeassistant.update_entity
|
||||
target:
|
||||
entity_id: camera.backyard_shed
|
||||
- delay:
|
||||
hours: 0
|
||||
minutes: 0
|
||||
seconds: 2
|
||||
milliseconds: 0
|
||||
- variables:
|
||||
snapshot_filename: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg
|
||||
- variables:
|
||||
@@ -1784,8 +1782,8 @@
|
||||
mode: single
|
||||
- id: '1783737753660'
|
||||
alias: 'Office: Manual Meeting Status Busy Light - Working Hours'
|
||||
description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours:
|
||||
Pure motion night light (Toggle ignored).'
|
||||
description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours
|
||||
OR Away Mode: Pure motion night light (Toggle ignored).'
|
||||
triggers:
|
||||
- id: status_changed
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
@@ -1807,6 +1805,9 @@
|
||||
- condition: state
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
state: 'on'
|
||||
- condition: state
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'off'
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
@@ -1832,6 +1833,9 @@
|
||||
- condition: state
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
state: 'off'
|
||||
- condition: state
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'off'
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
@@ -1854,17 +1858,22 @@
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: motion_detected
|
||||
- condition: not
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
- condition: state
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'on'
|
||||
- condition: not
|
||||
conditions:
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
sequence:
|
||||
- target:
|
||||
entity_id: light.night_light
|
||||
@@ -1878,22 +1887,27 @@
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: motion_cleared
|
||||
- condition: not
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
- condition: state
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'on'
|
||||
- condition: not
|
||||
conditions:
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
sequence:
|
||||
- target:
|
||||
entity_id: light.night_light
|
||||
action: light.turn_off
|
||||
mode: single
|
||||
mode: restart
|
||||
- id: '1783738120074'
|
||||
alias: 'Office: Webhook Toggle Meeting Status'
|
||||
description: Toggles the meeting status boolean when the secret webhook URL is opened
|
||||
@@ -2316,6 +2330,13 @@
|
||||
- light.office_lamp
|
||||
- light.playroom_light
|
||||
scroll_wheel_mode_ext_ch3: instant
|
||||
on_hold_action_ch1:
|
||||
- action: light.turn_on
|
||||
metadata: {}
|
||||
target:
|
||||
entity_id: light.playroom_light
|
||||
data:
|
||||
brightness_pct: 30
|
||||
- id: '1784073759556'
|
||||
alias: 'Office: Desk Lamp Meeting Alert'
|
||||
description: Flashes the desk lamp color temperature for a few seconds when a meeting
|
||||
@@ -2345,3 +2366,164 @@
|
||||
entity_id: scene.desk_lamp_before_alert
|
||||
data: {}
|
||||
mode: restart
|
||||
- id: '1784153893584'
|
||||
alias: 'Office: Manual Meeting Status Busy Light - Working Hours v2'
|
||||
description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours
|
||||
OR Away Mode: Pure motion night light (Toggle ignored).'
|
||||
triggers:
|
||||
- id: status_changed
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
trigger: state
|
||||
- id: motion_detected
|
||||
entity_id: binary_sensor.night_light_occupancy
|
||||
trigger: state
|
||||
to: 'on'
|
||||
- id: motion_cleared
|
||||
entity_id: binary_sensor.night_light_occupancy
|
||||
trigger: state
|
||||
to: 'off'
|
||||
conditions: []
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: status_changed
|
||||
- condition: state
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
state: 'on'
|
||||
- condition: state
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'off'
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
sequence:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.night_light
|
||||
data:
|
||||
brightness_pct: 100
|
||||
rgb_color:
|
||||
- 255
|
||||
- 0
|
||||
- 0
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: status_changed
|
||||
- condition: state
|
||||
entity_id: input_boolean.in_a_meeting
|
||||
state: 'off'
|
||||
- condition: state
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'off'
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
sequence:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.night_light
|
||||
data:
|
||||
brightness_pct: 20
|
||||
rgb_color:
|
||||
- 5
|
||||
- 245
|
||||
- 45
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: motion_detected
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'on'
|
||||
- condition: not
|
||||
conditions:
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
sequence:
|
||||
- action: light.turn_on
|
||||
target:
|
||||
entity_id: light.night_light
|
||||
data:
|
||||
brightness_pct: 30
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: motion_cleared
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: input_boolean.away_mode
|
||||
state: 'on'
|
||||
- condition: not
|
||||
conditions:
|
||||
- condition: time
|
||||
after: 08:00:00
|
||||
before: '18:00:00'
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- wed
|
||||
- thu
|
||||
- fri
|
||||
sequence:
|
||||
- delay: 00:00:02
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: light.night_light
|
||||
mode: restart
|
||||
- id: '1784246257858'
|
||||
alias: Sync Alexa Volume Slider
|
||||
description: Syncs the Alexa Master Volume slider with the currently selected speaker.
|
||||
triggers:
|
||||
- entity_id: input_number.alexa_master_volume
|
||||
id: slider_changed
|
||||
trigger: state
|
||||
- entity_id: input_select.alexa_target_device
|
||||
id: speaker_changed
|
||||
trigger: state
|
||||
conditions: []
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: slider_changed
|
||||
sequence:
|
||||
- action: media_player.volume_set
|
||||
target:
|
||||
entity_id: '{{ states(''input_select.alexa_target_device'') }}'
|
||||
data:
|
||||
volume_level: '{{ states(''input_number.alexa_master_volume'') | float /
|
||||
100 }}'
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: speaker_changed
|
||||
sequence:
|
||||
- action: input_number.set_value
|
||||
target:
|
||||
entity_id: input_number.alexa_master_volume
|
||||
data:
|
||||
value: "{% set target = states('input_select.alexa_target_device') %} {%
|
||||
if state_attr(target, 'volume_level') != none %}\n {{ (state_attr(target,
|
||||
'volume_level') | float * 100) | round(0) }}\n{% else %}\n 20\n{% endif
|
||||
%}"
|
||||
mode: restart
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Alexa Devices Alarm Control Panel using Guard Mode.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
from asyncio import sleep
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from alexapy import hide_email, hide_serial
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity
|
||||
from homeassistant.const import CONF_EMAIL, STATE_UNAVAILABLE
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .alexa_entity import parse_guard_state_from_coordinator
|
||||
from .alexa_media import AlexaMedia
|
||||
from .const import (
|
||||
CONF_EXCLUDE_DEVICES,
|
||||
CONF_INCLUDE_DEVICES,
|
||||
CONF_QUEUE_DELAY,
|
||||
DATA_ALEXAMEDIA,
|
||||
DEFAULT_QUEUE_DELAY,
|
||||
DOMAIN as ALEXA_DOMAIN,
|
||||
)
|
||||
from .helpers import _catch_login_errors, add_devices, safe_get
|
||||
|
||||
try:
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
|
||||
|
||||
STATE_ALARM_ARMED_AWAY = AlarmControlPanelState.ARMED_AWAY
|
||||
STATE_ALARM_DISARMED = AlarmControlPanelState.DISARMED
|
||||
except ImportError:
|
||||
from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = [ALEXA_DOMAIN]
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass, config, add_devices_callback, discovery_info=None
|
||||
) -> bool:
|
||||
"""Set up the Alexa alarm control panel platform."""
|
||||
devices: list[AlexaAlarmControlPanel] = []
|
||||
account = None
|
||||
if config:
|
||||
account = config.get(CONF_EMAIL)
|
||||
if account is None and discovery_info:
|
||||
account = safe_get(discovery_info, ["config", CONF_EMAIL])
|
||||
if account is None:
|
||||
raise ConfigEntryNotReady
|
||||
include_filter = config.get(CONF_INCLUDE_DEVICES, [])
|
||||
exclude_filter = config.get(CONF_EXCLUDE_DEVICES, [])
|
||||
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
|
||||
guard_media_players = {}
|
||||
for key, device in account_dict["devices"]["media_player"].items():
|
||||
if key not in account_dict["entities"]["media_player"]:
|
||||
_LOGGER.debug(
|
||||
"%s: Media player %s not loaded yet; delaying load",
|
||||
hide_email(account),
|
||||
hide_serial(key),
|
||||
)
|
||||
raise ConfigEntryNotReady
|
||||
if "GUARD_EARCON" in device["capabilities"]:
|
||||
guard_media_players[key] = account_dict["entities"]["media_player"][key]
|
||||
if "alarm_control_panel" not in (account_dict["entities"]):
|
||||
(
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][
|
||||
"alarm_control_panel"
|
||||
]
|
||||
) = {}
|
||||
alexa_client: Optional[AlexaAlarmControlPanel] = None
|
||||
guard_entities = safe_get(account_dict, ["devices", "guard"], [])
|
||||
if guard_entities:
|
||||
alexa_client = AlexaAlarmControlPanel(
|
||||
account_dict["login_obj"],
|
||||
account_dict["coordinator"],
|
||||
guard_entities[0],
|
||||
guard_media_players,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("%s: No Alexa Guard entity found", hide_email(account))
|
||||
if not (alexa_client and alexa_client.unique_id):
|
||||
_LOGGER.debug(
|
||||
"%s: Skipping creation of uninitialized device: %s",
|
||||
hide_email(account),
|
||||
alexa_client,
|
||||
)
|
||||
elif alexa_client.unique_id not in (
|
||||
account_dict["entities"]["alarm_control_panel"]
|
||||
):
|
||||
devices.append(alexa_client)
|
||||
(
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][
|
||||
"alarm_control_panel"
|
||||
][alexa_client.unique_id]
|
||||
) = alexa_client
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s: Skipping already added device: %s", hide_email(account), alexa_client
|
||||
)
|
||||
return await add_devices(
|
||||
hide_email(account),
|
||||
devices,
|
||||
add_devices_callback,
|
||||
include_filter,
|
||||
exclude_filter,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the Alexa alarm control panel platform by config_entry."""
|
||||
return await async_setup_platform(
|
||||
hass, config_entry.data, async_add_devices, discovery_info=None
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
account = entry.data[CONF_EMAIL]
|
||||
_LOGGER.debug("Attempting to unload alarm control panel")
|
||||
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
|
||||
for device in account_dict["entities"]["alarm_control_panel"].values():
|
||||
_LOGGER.debug("Removing %s", device)
|
||||
await device.async_remove()
|
||||
return True
|
||||
|
||||
|
||||
class AlexaAlarmControlPanel(AlarmControlPanelEntity, AlexaMedia, CoordinatorEntity):
|
||||
"""Implementation of Alexa Media Player alarm control panel."""
|
||||
|
||||
def __init__(self, login, coordinator, guard_entity, media_players=None) -> None:
|
||||
"""Initialize the Alexa device."""
|
||||
AlexaMedia.__init__(self, None, login)
|
||||
CoordinatorEntity.__init__(self, coordinator)
|
||||
_LOGGER.debug("%s: Initiating alarm control panel", hide_email(login.email))
|
||||
# AlexaAPI requires a AlexaClient object, need to clean this up
|
||||
|
||||
# Guard info
|
||||
self._appliance_id = guard_entity["appliance_id"]
|
||||
self._guard_entity_id = guard_entity["id"]
|
||||
self._friendly_name = "Alexa Guard " + self._appliance_id[-5:]
|
||||
self._media_players = {} or media_players
|
||||
self._attrs: dict[str, str] = {}
|
||||
_LOGGER.debug(
|
||||
"%s: Guard Discovered %s: %s %s",
|
||||
self.account,
|
||||
self._friendly_name,
|
||||
hide_serial(self._appliance_id),
|
||||
hide_serial(self._guard_entity_id),
|
||||
)
|
||||
|
||||
@_catch_login_errors
|
||||
async def _async_alarm_set(
|
||||
self,
|
||||
command: str = "",
|
||||
code=None, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
"""Send command."""
|
||||
try:
|
||||
if not self.enabled:
|
||||
return
|
||||
except AttributeError:
|
||||
pass
|
||||
if command not in (STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED):
|
||||
_LOGGER.error("Invalid command: %s", command)
|
||||
return
|
||||
command_map = {STATE_ALARM_ARMED_AWAY: "AWAY", STATE_ALARM_DISARMED: "HOME"}
|
||||
available_media_players = list(
|
||||
filter(lambda x: x.state != STATE_UNAVAILABLE, self._media_players.values())
|
||||
)
|
||||
if available_media_players:
|
||||
_LOGGER.debug("Sending guard command to: %s", available_media_players[0])
|
||||
available_media_players[0].check_login_changes()
|
||||
# Extract appliance ID safely to prevent IndexError if format is unexpected
|
||||
appliance_parts = self._appliance_id.split("_")
|
||||
appliance_id = (
|
||||
appliance_parts[2] if len(appliance_parts) > 2 else self._appliance_id
|
||||
)
|
||||
await available_media_players[0].alexa_api.set_guard_state(
|
||||
appliance_id,
|
||||
command_map[command],
|
||||
queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][
|
||||
"options"
|
||||
].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY),
|
||||
)
|
||||
await sleep(2) # delay
|
||||
else:
|
||||
_LOGGER.debug("Performing static guard command")
|
||||
await self.alexa_api.static_set_guard_state(
|
||||
self._login, self._guard_entity_id, command
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_alarm_disarm(
|
||||
self,
|
||||
code=None, # pylint:disable=unused-argument
|
||||
) -> None:
|
||||
"""Send disarm command."""
|
||||
await self._async_alarm_set(STATE_ALARM_DISARMED)
|
||||
|
||||
async def async_alarm_arm_away(
|
||||
self,
|
||||
code=None, # pylint:disable=unused-argument
|
||||
) -> None:
|
||||
"""Send arm away command."""
|
||||
await self._async_alarm_set(STATE_ALARM_ARMED_AWAY)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique ID."""
|
||||
return self._guard_entity_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._friendly_name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
_state = parse_guard_state_from_coordinator(
|
||||
self.coordinator, self._guard_entity_id
|
||||
)
|
||||
if _state == "ARMED_AWAY":
|
||||
return STATE_ALARM_ARMED_AWAY
|
||||
return STATE_ALARM_DISARMED
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Return the list of supported features."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
try:
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntityFeature,
|
||||
)
|
||||
except ImportError:
|
||||
return 0
|
||||
return AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
@property
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return assumed state.
|
||||
|
||||
Returns
|
||||
bool: Whether the state is assumed
|
||||
|
||||
"""
|
||||
last_refresh_success = (
|
||||
self.coordinator.data and self._guard_entity_id in self.coordinator.data
|
||||
)
|
||||
return not last_refresh_success
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return self._attrs
|
||||
@@ -0,0 +1,764 @@
|
||||
"""
|
||||
Alexa Devices Entities.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional, TypedDict
|
||||
|
||||
from alexapy import AlexaAPI, AlexaLogin
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .helpers import safe_get
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How long we keep "requested state" protected from stale coordinator values
|
||||
# when capabilityStates do not include a usable timeOfSample.
|
||||
_REQUESTED_STATE_TTL = timedelta(seconds=15)
|
||||
|
||||
|
||||
def has_capability(
|
||||
appliance: dict[str, Any], interface_name: str, property_name: str
|
||||
) -> bool:
|
||||
"""Determine if an appliance from the Alexa network details offers a particular interface with enough support that is worth adding to Home Assistant.
|
||||
|
||||
Args:
|
||||
appliance(dict[str, Any]): An appliance from a call to AlexaAPI.get_network_details
|
||||
interface_name(str): One of the interfaces documented by the Alexa Smart Home Skills API
|
||||
property_name(str): The property that matches the interface name.
|
||||
|
||||
"""
|
||||
for cap in appliance["capabilities"]:
|
||||
props = cap.get("properties")
|
||||
if (
|
||||
cap["interfaceName"] == interface_name
|
||||
and props
|
||||
and (props["retrievable"] or props["proactivelyReported"])
|
||||
):
|
||||
for prop in props["supported"]:
|
||||
if prop["name"] == property_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_hue_v1(appliance: dict[str, Any]) -> bool:
|
||||
"""Determine if an appliance is managed via the Philips Hue v1 Hub.
|
||||
|
||||
This check catches old Philips Hue bulbs and hubs, but critically, it also catches things pretending to be older
|
||||
Philips Hue bulbs and hubs. This includes things exposed by HA to Alexa using the emulated_hue integration.
|
||||
"""
|
||||
return appliance.get("manufacturerName") == "Royal Philips Electronics"
|
||||
|
||||
|
||||
def is_skill(appliance: dict[str, Any]) -> bool:
|
||||
namespace = safe_get(appliance, ["driverIdentity", "namespace"], "")
|
||||
return namespace and namespace == "SKILL"
|
||||
|
||||
|
||||
def is_known_ha_bridge(appliance: dict[str, Any] | None) -> bool:
|
||||
"""Test whether a bridge appliance is a known HA bridge to avoid creating loops."""
|
||||
|
||||
if appliance is None:
|
||||
return False
|
||||
|
||||
if appliance.get("manufacturerName") in ("t0bst4r", "Matterbridge"):
|
||||
return True
|
||||
|
||||
# Identify Matter bridge hubs regardless of manufacturerName
|
||||
if "HUB" in appliance.get("applianceTypes", []):
|
||||
driver_ns = safe_get(appliance, ["driverIdentity", "namespace"], "")
|
||||
driver_id = safe_get(appliance, ["driverIdentity", "identifier"], "")
|
||||
if driver_ns == "AAA" and driver_id == "SonarCloudService":
|
||||
interfaces = {
|
||||
cap.get("interfaceName") for cap in appliance.get("capabilities", [])
|
||||
}
|
||||
if (
|
||||
"Alexa.Matter.NodeOperationalCredentials.FabricManagement" in interfaces
|
||||
or "Alexa.Commissionable" in interfaces
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_local(appliance: dict[str, Any]) -> bool:
|
||||
"""Test whether locally connected.
|
||||
|
||||
This is mainly present to prevent loops with the official Alexa integration.
|
||||
There is probably a better way to prevent that, but this works.
|
||||
"""
|
||||
|
||||
if appliance.get("connectedVia"):
|
||||
# connectedVia is a flag that determines which Echo devices holds the connection. Its blank for
|
||||
# skill derived devices and includes an Echo name for zigbee and local devices.
|
||||
return True
|
||||
|
||||
# This catches the Echo/AVS devices. connectedVia isn't reliable in this case.
|
||||
# Only the first appears to get that set.
|
||||
if "ALEXA_VOICE_ENABLED" in appliance.get("applianceTypes", []):
|
||||
return not is_skill(appliance)
|
||||
|
||||
# Ledvance/Sengled bulbs connected via bluetooth are hard to detect as locally connected
|
||||
# Amazon devices are not local but bypassing the local check allows for control by the integration
|
||||
# There is probably a better way, but this works for now.
|
||||
manufacturerNames = ["Ledvance", "Sengled", "Amazon"]
|
||||
if appliance.get("manufacturerName") in manufacturerNames:
|
||||
return not is_skill(appliance)
|
||||
|
||||
# Zigbee devices are guaranteed to be local and have a particular pattern of id
|
||||
zigbee_pattern = re.compile(
|
||||
"AAA_SonarCloudService_([0-9A-F][0-9A-F]:){7}[0-9A-F][0-9A-F]", flags=re.I
|
||||
)
|
||||
return zigbee_pattern.fullmatch(appliance.get("applianceId", "")) is not None
|
||||
|
||||
|
||||
def is_alexa_guard(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance the guard alarm system of an echo."""
|
||||
return appliance["modelName"] == "REDROCK_GUARD_PANEL" and has_capability(
|
||||
appliance, "Alexa.SecurityPanelController", "armState"
|
||||
)
|
||||
|
||||
|
||||
def is_temperature_sensor(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance the temperature sensor of an Echo."""
|
||||
return (
|
||||
is_local(appliance)
|
||||
and has_capability(appliance, "Alexa.TemperatureSensor", "temperature")
|
||||
and appliance["friendlyDescription"] != "Amazon Indoor Air Quality Monitor"
|
||||
)
|
||||
|
||||
|
||||
# Checks if air quality sensor
|
||||
def is_air_quality_sensor(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance the Amazon Indoor Air Quality Monitor (AIAQM)."""
|
||||
return (
|
||||
appliance.get("friendlyDescription") == "Amazon Indoor Air Quality Monitor"
|
||||
and "AIR_QUALITY_MONITOR" in appliance.get("applianceTypes", [])
|
||||
and has_capability(appliance, "Alexa.RangeController", "rangeValue")
|
||||
)
|
||||
|
||||
|
||||
def is_light(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance a light controlled locally by an Echo."""
|
||||
return (
|
||||
is_local(appliance)
|
||||
and (
|
||||
"LIGHT" in appliance.get("applianceTypes", [])
|
||||
or (
|
||||
"SMARTPLUG" in appliance.get("applianceTypes", [])
|
||||
and appliance.get("customerDefinedDeviceType") == "LIGHT"
|
||||
)
|
||||
)
|
||||
and has_capability(appliance, "Alexa.PowerController", "powerState")
|
||||
)
|
||||
|
||||
|
||||
def is_contact_sensor(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance a contact sensor controlled locally by an Echo."""
|
||||
return (
|
||||
is_local(appliance)
|
||||
and "CONTACT_SENSOR" in appliance.get("applianceTypes", [])
|
||||
and has_capability(appliance, "Alexa.ContactSensor", "detectionState")
|
||||
)
|
||||
|
||||
|
||||
def is_switch(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance a switch controlled locally by an Echo, which is not redeclared as a light."""
|
||||
return (
|
||||
is_local(appliance)
|
||||
and (
|
||||
"SMARTPLUG" in appliance.get("applianceTypes", [])
|
||||
or "SWITCH" in appliance.get("applianceTypes", [])
|
||||
)
|
||||
and appliance.get("customerDefinedDeviceType") != "LIGHT"
|
||||
and has_capability(appliance, "Alexa.PowerController", "powerState")
|
||||
)
|
||||
|
||||
|
||||
def get_friendliest_name(appliance: dict[str, Any]) -> str:
|
||||
"""Find the best friendly name. Alexa seems to store manual renames in aliases. Prefer that one."""
|
||||
aliases = appliance.get("aliases", [])
|
||||
for alias in aliases:
|
||||
friendly = alias.get("friendlyName")
|
||||
if friendly:
|
||||
return friendly
|
||||
return appliance["friendlyName"]
|
||||
|
||||
|
||||
def get_device_serial(appliance: dict[str, Any]) -> str | None:
|
||||
"""Find the device serial id if it is present."""
|
||||
alexa_device_id_list = appliance.get("alexaDeviceIdentifierList", [])
|
||||
for alexa_device_id in alexa_device_id_list:
|
||||
if isinstance(alexa_device_id, dict):
|
||||
return alexa_device_id.get("dmsDeviceSerialNumber")
|
||||
return None
|
||||
|
||||
|
||||
def get_device_bridge(
|
||||
appliance: dict[str, Any], appliances: dict[str, dict[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Find the bridge device for an appliance connected through e.g. a Matter bridge."""
|
||||
|
||||
appliance_id = appliance.get("applianceId")
|
||||
if not isinstance(appliance_id, str) or "#" not in appliance_id:
|
||||
return None
|
||||
|
||||
# HA Matter Hub bridged endpoints are identified by applianceId prefixes
|
||||
# of the form AAA_SonarCloudService_<bridgeId>#<childId>.
|
||||
bridge_id, _sep, _child = appliance_id.partition("#")
|
||||
|
||||
if not bridge_id.startswith("AAA_SonarCloudService_"):
|
||||
return None
|
||||
|
||||
bridge = appliances.get(bridge_id)
|
||||
return bridge if isinstance(bridge, dict) else None
|
||||
|
||||
|
||||
AlexaEntityData = dict[str, list["AlexaCapabilityState"]]
|
||||
|
||||
|
||||
class AlexaEntity(TypedDict):
|
||||
"""Class for Alexaentity."""
|
||||
|
||||
id: str
|
||||
appliance_id: str
|
||||
name: str
|
||||
is_hue_v1: bool
|
||||
|
||||
|
||||
class AlexaLightEntity(AlexaEntity):
|
||||
"""Class for AlexaLightEntity."""
|
||||
|
||||
brightness: bool
|
||||
color: bool
|
||||
color_temperature: bool
|
||||
|
||||
|
||||
class AlexaTemperatureEntity(TypedDict, total=False):
|
||||
device_serial: str
|
||||
is_aiaqm: bool
|
||||
|
||||
|
||||
class AlexaAirQualityEntity(AlexaEntity):
|
||||
"""Class for AlexaAirQualityEntity."""
|
||||
|
||||
device_serial: str
|
||||
|
||||
|
||||
class AlexaAIAQMEntity(AlexaEntity):
|
||||
"""Entity-backed "device" representing an Amazon Indoor Air Quality Monitor."""
|
||||
|
||||
device_serial: str
|
||||
sensors: list[dict[str, str]]
|
||||
|
||||
|
||||
class AlexaBinaryEntity(AlexaEntity):
|
||||
"""Class for AlexaBinaryEntity."""
|
||||
|
||||
battery_level: bool
|
||||
|
||||
|
||||
class AlexaEntities(TypedDict):
|
||||
"""Class for Alexa Entities."""
|
||||
|
||||
light: list[AlexaLightEntity]
|
||||
guard: list[AlexaEntity]
|
||||
temperature: list[AlexaTemperatureEntity]
|
||||
air_quality: list[AlexaAirQualityEntity]
|
||||
aiaqm: list[AlexaAIAQMEntity]
|
||||
binary_sensor: list[AlexaBinaryEntity]
|
||||
smart_switch: list[AlexaEntity]
|
||||
|
||||
|
||||
class AlexaCapabilityState(TypedDict, total=False):
|
||||
"""Class for AlexaCapabilityState."""
|
||||
|
||||
name: str
|
||||
namespace: str
|
||||
value: int | float | str | dict[str, Any]
|
||||
instance: str
|
||||
timeOfSample: str
|
||||
uncertaintyInMilliseconds: int
|
||||
|
||||
|
||||
def parse_alexa_entities(
|
||||
network_details: list[dict[str, Any]] | None,
|
||||
debug: bool = False,
|
||||
) -> AlexaEntities:
|
||||
# pylint: disable=too-many-locals
|
||||
"""Turn the network details into a list of useful entities with the important details extracted."""
|
||||
temperature_sensors: list[AlexaTemperatureEntity] = []
|
||||
air_quality_sensors: list[AlexaAirQualityEntity] = []
|
||||
aiaqm_entities: list[AlexaAIAQMEntity] = []
|
||||
contact_sensors: list[AlexaBinaryEntity] = []
|
||||
switches: list[AlexaEntity] = []
|
||||
guards: list[AlexaEntity] = []
|
||||
lights: list[AlexaLightEntity] = []
|
||||
|
||||
function_name = "parse_alexa_entities()"
|
||||
|
||||
if not network_details:
|
||||
return {
|
||||
"light": lights,
|
||||
"guard": guards,
|
||||
"temperature": temperature_sensors,
|
||||
"air_quality": air_quality_sensors,
|
||||
"aiaqm": aiaqm_entities,
|
||||
"binary_sensor": contact_sensors,
|
||||
"smart_switch": switches,
|
||||
}
|
||||
|
||||
network_dict: dict[str, dict[str, Any]] = {}
|
||||
if debug:
|
||||
_LOGGER.debug("Processing network_details")
|
||||
|
||||
# Build an applianceId → appliance map first so bridged devices
|
||||
# can resolve their bridge regardless of list ordering.
|
||||
for appliance in network_details:
|
||||
appliance_id = appliance.get("applianceId")
|
||||
if appliance_id:
|
||||
network_dict[appliance_id] = appliance
|
||||
|
||||
for appliance in network_details:
|
||||
device_bridge = get_device_bridge(appliance, network_dict)
|
||||
|
||||
bridge_label = (
|
||||
device_bridge.get("friendlyName") or device_bridge.get("manufacturerName")
|
||||
if device_bridge
|
||||
else None
|
||||
)
|
||||
|
||||
appliance_id = str(appliance.get("applianceId", ""))
|
||||
|
||||
# Only log a bridge check when:
|
||||
# - we found a bridge, OR
|
||||
# - ADV debug is enabled AND the appliance looks like a bridge candidate
|
||||
if bridge_label is not None or (debug and "#" in appliance_id):
|
||||
_LOGGER.debug(
|
||||
"%s: Checking device bridge: %s",
|
||||
appliance.get("friendlyName"),
|
||||
bridge_label or "<none>",
|
||||
)
|
||||
|
||||
# ADV-only: only log resolution for cases where it might apply
|
||||
if debug and "#" in appliance_id:
|
||||
bridge_id = device_bridge.get("applianceId") if device_bridge else None
|
||||
_LOGGER.debug(
|
||||
"[%s] [ADV] Matter bridge resolution: appliance=%s → bridge=%s (connectedVia=%s, bridge=%s)",
|
||||
function_name,
|
||||
appliance_id,
|
||||
bridge_id,
|
||||
appliance.get("connectedVia"),
|
||||
bridge_label,
|
||||
)
|
||||
|
||||
if is_known_ha_bridge(device_bridge):
|
||||
if debug:
|
||||
_LOGGER.debug(
|
||||
'[%s] [ADV] Skipping bridged Matter device "%s" (%s) via known bridge: %s (%s)',
|
||||
function_name,
|
||||
appliance.get("friendlyName"),
|
||||
appliance.get("applianceId"),
|
||||
bridge_label,
|
||||
device_bridge.get("applianceId") if device_bridge else None,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
'Skipping bridged Matter device "%s" via known bridge "%s"',
|
||||
appliance.get("friendlyName"),
|
||||
bridge_label or "<unknown>",
|
||||
)
|
||||
continue
|
||||
|
||||
processed_appliance: AlexaEntity = {
|
||||
"id": appliance["entityId"],
|
||||
"appliance_id": appliance["applianceId"],
|
||||
"name": get_friendliest_name(appliance),
|
||||
"is_hue_v1": is_hue_v1(appliance),
|
||||
}
|
||||
|
||||
if is_alexa_guard(appliance):
|
||||
_LOGGER.debug("Added Alexa Guard: %s", processed_appliance["name"])
|
||||
guards.append(processed_appliance)
|
||||
|
||||
elif is_temperature_sensor(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug(
|
||||
"Added temperature sensor: %s", processed_appliance["name"]
|
||||
)
|
||||
serial = get_device_serial(appliance)
|
||||
temp_entity: AlexaTemperatureEntity = {
|
||||
**processed_appliance,
|
||||
"device_serial": serial if serial else appliance["entityId"],
|
||||
}
|
||||
temperature_sensors.append(temp_entity)
|
||||
|
||||
elif is_air_quality_sensor(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug("Added AIAQM sensor: %s", processed_appliance["name"])
|
||||
|
||||
serial = get_device_serial(appliance)
|
||||
device_serial = serial if serial else appliance["entityId"]
|
||||
|
||||
# Build a list of sub-sensors we can read via AlexaAPI.get_entity_state.
|
||||
# AIAQM metrics are exposed via Alexa.RangeController(rangeValue) with an
|
||||
# instance per metric. Some accounts/devices use numeric instances, so
|
||||
# we derive the sensor type from the friendlyName assetId/text.
|
||||
sensors: list[dict[str, str]] = []
|
||||
for cap in appliance.get("capabilities", []):
|
||||
if cap.get("interfaceName") != "Alexa.RangeController":
|
||||
continue
|
||||
|
||||
# Must support numeric rangeValue to be a sensor.
|
||||
supported = safe_get(cap, ["properties", "supported"], [])
|
||||
if not isinstance(supported, list) or not any(
|
||||
isinstance(p, dict) and p.get("name") == "rangeValue"
|
||||
for p in supported
|
||||
):
|
||||
continue
|
||||
|
||||
instance = cap.get("instance")
|
||||
if instance is None or instance == "":
|
||||
continue
|
||||
if not isinstance(instance, str):
|
||||
if isinstance(instance, (int, float)):
|
||||
instance = str(instance)
|
||||
else:
|
||||
continue
|
||||
|
||||
unit = safe_get(cap, ["configuration", "unitOfMeasure"], "") or ""
|
||||
|
||||
resources = (
|
||||
cap.get("resources", {})
|
||||
if isinstance(cap.get("resources"), dict)
|
||||
else {}
|
||||
)
|
||||
friendly = (
|
||||
resources.get("friendlyNames", [])
|
||||
if isinstance(resources.get("friendlyNames"), list)
|
||||
else []
|
||||
)
|
||||
|
||||
sensor_type: str | None = None
|
||||
for entry in friendly:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
value_obj = entry.get("value")
|
||||
asset_id = None
|
||||
if isinstance(value_obj, dict):
|
||||
asset_id = value_obj.get("assetId")
|
||||
else:
|
||||
asset_id = entry.get("assetId")
|
||||
|
||||
# Only treat Alexa.AirQuality assetIds as real AIAQM sensors.
|
||||
# Text-only friendlyNames (e.g. @type "text") must be ignored to avoid
|
||||
# creating extra sensors such as PM10.
|
||||
if isinstance(asset_id, str) and asset_id.startswith(
|
||||
"Alexa.AirQuality."
|
||||
):
|
||||
sensor_type = asset_id
|
||||
break
|
||||
|
||||
if not sensor_type:
|
||||
continue
|
||||
sensors.append(
|
||||
{
|
||||
"sensorType": str(sensor_type),
|
||||
"instance": instance,
|
||||
"unit": str(unit),
|
||||
}
|
||||
)
|
||||
|
||||
# Always register the AIAQM device (even if no sub-sensors are exposed).
|
||||
aiaqm_entity: AlexaAIAQMEntity = {
|
||||
**processed_appliance,
|
||||
"device_serial": device_serial,
|
||||
"sensors": sensors,
|
||||
}
|
||||
aiaqm_entities.append(aiaqm_entity)
|
||||
|
||||
# Backwards compatibility: also expose as air_quality for existing paths.
|
||||
aq_entity: AlexaAirQualityEntity = {
|
||||
**processed_appliance,
|
||||
"device_serial": device_serial,
|
||||
}
|
||||
air_quality_sensors.append(aq_entity)
|
||||
|
||||
# AIAQM also has temperature; ensure it gets created and grouped with AIAQM.
|
||||
temp_entity: AlexaTemperatureEntity = {
|
||||
**processed_appliance,
|
||||
"device_serial": device_serial,
|
||||
"is_aiaqm": True,
|
||||
}
|
||||
temperature_sensors.append(temp_entity)
|
||||
elif is_switch(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug("Added switch: %s", processed_appliance["name"])
|
||||
switches.append(processed_appliance)
|
||||
|
||||
elif is_light(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug("Added light %s", processed_appliance["name"])
|
||||
processed_appliance["brightness"] = has_capability(
|
||||
appliance, "Alexa.BrightnessController", "brightness"
|
||||
)
|
||||
processed_appliance["color"] = has_capability(
|
||||
appliance, "Alexa.ColorController", "color"
|
||||
)
|
||||
processed_appliance["color_temperature"] = has_capability(
|
||||
appliance,
|
||||
"Alexa.ColorTemperatureController",
|
||||
"colorTemperatureInKelvin",
|
||||
)
|
||||
light_entity: AlexaLightEntity = {
|
||||
**processed_appliance,
|
||||
"brightness": processed_appliance["brightness"],
|
||||
"color": processed_appliance["color"],
|
||||
"color_temperature": processed_appliance["color_temperature"],
|
||||
}
|
||||
lights.append(light_entity)
|
||||
|
||||
elif is_contact_sensor(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug("Added contact sensor: %s", processed_appliance["name"])
|
||||
processed_appliance["battery_level"] = has_capability(
|
||||
appliance, "Alexa.BatteryLevelSensor", "batteryLevel"
|
||||
)
|
||||
binary_entity: AlexaBinaryEntity = {
|
||||
**processed_appliance,
|
||||
"battery_level": processed_appliance["battery_level"],
|
||||
}
|
||||
contact_sensors.append(binary_entity)
|
||||
|
||||
else:
|
||||
if debug:
|
||||
_LOGGER.debug("Unsupported entity: %s", processed_appliance["name"])
|
||||
|
||||
return {
|
||||
"light": lights,
|
||||
"guard": guards,
|
||||
"temperature": temperature_sensors,
|
||||
"air_quality": air_quality_sensors,
|
||||
"aiaqm": aiaqm_entities,
|
||||
"binary_sensor": contact_sensors,
|
||||
"smart_switch": switches,
|
||||
}
|
||||
|
||||
|
||||
async def get_entity_data(
|
||||
login_obj: AlexaLogin, entity_ids: list[str]
|
||||
) -> AlexaEntityData:
|
||||
"""Get and process the entity data into a more usable format."""
|
||||
|
||||
entities = {}
|
||||
if entity_ids:
|
||||
raw = await AlexaAPI.get_entity_state(login_obj, entity_ids=entity_ids)
|
||||
device_states = raw.get("deviceStates", []) if isinstance(raw, dict) else None
|
||||
if device_states:
|
||||
for device_state in device_states:
|
||||
entity_id = safe_get(device_state, ["entity", "entityId"])
|
||||
if entity_id:
|
||||
entities[entity_id] = []
|
||||
cap_states = device_state.get("capabilityStates", [])
|
||||
for cap_state in cap_states:
|
||||
entities[entity_id].append(json.loads(cap_state))
|
||||
return entities
|
||||
|
||||
|
||||
def parse_temperature_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_id: str,
|
||||
debug: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get the temperature of an entity from the coordinator data."""
|
||||
temperature = parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.TemperatureSensor",
|
||||
"temperature",
|
||||
debug=debug,
|
||||
)
|
||||
if debug:
|
||||
_LOGGER.debug("parse_temperature_from_coordinator: %s", temperature)
|
||||
return temperature
|
||||
|
||||
|
||||
def parse_air_quality_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_id: str,
|
||||
instance_id: str,
|
||||
debug: bool = False,
|
||||
) -> int | float | str | None:
|
||||
"""Get the air quality of an entity from the coordinator data."""
|
||||
value = parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.RangeController",
|
||||
"rangeValue",
|
||||
instance=instance_id,
|
||||
debug=debug,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def parse_brightness_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
|
||||
) -> int | None:
|
||||
"""Get the brightness in the range 0-100."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.BrightnessController",
|
||||
"brightness",
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def parse_color_temp_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
|
||||
) -> int | None:
|
||||
"""Get the color temperature in kelvin."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.ColorTemperatureController",
|
||||
"colorTemperatureInKelvin",
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def parse_color_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
|
||||
) -> tuple[float, float, float] | None:
|
||||
"""Get the color as a tuple of (hue, saturation, brightness)."""
|
||||
value = parse_value_from_coordinator(
|
||||
coordinator, entity_id, "Alexa.ColorController", "color", since
|
||||
)
|
||||
if value is not None:
|
||||
hue = value.get("hue", 0)
|
||||
saturation = value.get("saturation", 0)
|
||||
return hue, saturation, 1
|
||||
return None
|
||||
|
||||
|
||||
def parse_power_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
|
||||
) -> str | None:
|
||||
"""Get the power state of the entity."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.PowerController",
|
||||
"powerState",
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def parse_guard_state_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str
|
||||
) -> str | None:
|
||||
"""Get the guard state from the coordinator data."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator, entity_id, "Alexa.SecurityPanelController", "armState"
|
||||
)
|
||||
|
||||
|
||||
def parse_detection_state_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str
|
||||
) -> bool | None:
|
||||
"""Get the detection state from the coordinator data."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator, entity_id, "Alexa.ContactSensor", "detectionState"
|
||||
)
|
||||
|
||||
|
||||
def parse_value_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_id: str,
|
||||
namespace: str,
|
||||
name: str,
|
||||
since: datetime | None = None,
|
||||
instance: str | None = None,
|
||||
*,
|
||||
debug: bool = False,
|
||||
) -> Any:
|
||||
"""Parse out values from coordinator for Alexa Entities."""
|
||||
if coordinator.data and entity_id in coordinator.data:
|
||||
found_match = False
|
||||
for cap_state in coordinator.data[entity_id]:
|
||||
cap_instance = cap_state.get("instance")
|
||||
instance_match = instance is None or (
|
||||
cap_instance is not None and str(cap_instance) == str(instance)
|
||||
)
|
||||
if (
|
||||
cap_state.get("namespace") == namespace
|
||||
and cap_state.get("name") == name
|
||||
and instance_match
|
||||
):
|
||||
found_match = True
|
||||
if is_cap_state_still_acceptable(cap_state, since):
|
||||
return cap_state.get("value")
|
||||
if debug:
|
||||
_LOGGER.debug(
|
||||
"Coordinator data for %s (%s/%s instance=%s) is too old; checking other matches.",
|
||||
entity_id,
|
||||
namespace,
|
||||
name,
|
||||
instance,
|
||||
)
|
||||
# Keep searching in case a newer matching cap_state exists later.
|
||||
continue
|
||||
if debug and found_match:
|
||||
_LOGGER.debug(
|
||||
"No acceptable coordinator data found for %s (%s/%s instance=%s).",
|
||||
entity_id,
|
||||
namespace,
|
||||
name,
|
||||
instance,
|
||||
)
|
||||
else:
|
||||
if debug:
|
||||
_LOGGER.debug(
|
||||
"Coordinator has no data yet for %s, %s, %s, %s",
|
||||
entity_id,
|
||||
namespace,
|
||||
name,
|
||||
instance,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def is_cap_state_still_acceptable(
|
||||
cap_state: dict[str, Any], since: datetime | None
|
||||
) -> bool:
|
||||
"""Determine if a particular capability state is still usable given its age."""
|
||||
if since is None:
|
||||
return True
|
||||
|
||||
# Don't protect requested state forever; after TTL fall back to coordinator
|
||||
# even if timeOfSample is missing/unparsable.
|
||||
if datetime.now(timezone.utc) - since > _REQUESTED_STATE_TTL:
|
||||
return True
|
||||
|
||||
formatted_time_of_sample = cap_state.get("timeOfSample")
|
||||
if not formatted_time_of_sample:
|
||||
# If we can't prove the sample is newer than the requested state,
|
||||
# do not allow it to override optimistic/requested values.
|
||||
return False
|
||||
|
||||
try:
|
||||
time_of_sample = datetime.fromisoformat(formatted_time_of_sample)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return time_of_sample >= since
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Alexa Devices Base Class.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from alexapy import AlexaAPI, hide_email
|
||||
|
||||
from .const import DATA_ALEXAMEDIA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlexaMedia:
|
||||
"""Implementation of Alexa Media Base object."""
|
||||
|
||||
def __init__(self, device, login) -> None:
|
||||
"""Initialize the Alexa device."""
|
||||
|
||||
# Class info
|
||||
self._login = login
|
||||
self.alexa_api = AlexaAPI(device, login)
|
||||
self.email = login.email
|
||||
self.account = hide_email(login.email)
|
||||
|
||||
def check_login_changes(self):
|
||||
"""Update Login object if it has changed."""
|
||||
# _LOGGER.debug("Checking if Login object has changed")
|
||||
try:
|
||||
login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email]["login_obj"]
|
||||
except (AttributeError, KeyError):
|
||||
return
|
||||
# _LOGGER.debug("Login object %s closed status: %s", login, login.session.closed)
|
||||
# _LOGGER.debug(
|
||||
# "Alexaapi %s closed status: %s",
|
||||
# self.alexa_api,
|
||||
# self.alexa_api._session.closed,
|
||||
# )
|
||||
if self.alexa_api.update_login(login):
|
||||
_LOGGER.debug("Login object has changed; updating")
|
||||
self._login = login
|
||||
self.email = login.email
|
||||
self.account = hide_email(login.email)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Alexa Devices Sensors.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from alexapy import hide_serial
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import (
|
||||
CONF_EMAIL,
|
||||
CONF_EXCLUDE_DEVICES,
|
||||
CONF_INCLUDE_DEVICES,
|
||||
DATA_ALEXAMEDIA,
|
||||
hide_email,
|
||||
)
|
||||
from .alexa_entity import parse_detection_state_from_coordinator
|
||||
from .const import CONF_EXTENDED_ENTITY_DISCOVERY
|
||||
from .helpers import add_devices, safe_get
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
"""Set up the Alexa sensor platform."""
|
||||
devices: list[BinarySensorEntity] = []
|
||||
account = None
|
||||
if config:
|
||||
account = config.get(CONF_EMAIL)
|
||||
if account is None and discovery_info:
|
||||
account = safe_get(discovery_info, ["config", CONF_EMAIL])
|
||||
if account is None:
|
||||
raise ConfigEntryNotReady
|
||||
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
|
||||
include_filter = config.get(CONF_INCLUDE_DEVICES, [])
|
||||
exclude_filter = config.get(CONF_EXCLUDE_DEVICES, [])
|
||||
coordinator = account_dict["coordinator"]
|
||||
binary_entities = safe_get(account_dict, ["devices", "binary_sensor"], [])
|
||||
if binary_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY):
|
||||
for binary_entity in binary_entities:
|
||||
_LOGGER.debug(
|
||||
"Creating entity %s for a binary_sensor with name %s",
|
||||
hide_serial(binary_entity["id"]),
|
||||
binary_entity["name"],
|
||||
)
|
||||
contact_sensor = AlexaContact(coordinator, binary_entity)
|
||||
account_dict["entities"]["binary_sensor"].append(contact_sensor)
|
||||
devices.append(contact_sensor)
|
||||
|
||||
return await add_devices(
|
||||
hide_email(account),
|
||||
devices,
|
||||
add_devices_callback,
|
||||
include_filter,
|
||||
exclude_filter,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the Alexa sensor platform by config_entry."""
|
||||
return await async_setup_platform(
|
||||
hass, config_entry.data, async_add_devices, discovery_info=None
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
account = entry.data[CONF_EMAIL]
|
||||
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
|
||||
_LOGGER.debug("Attempting to unload binary sensors")
|
||||
for binary_sensor in account_dict["entities"]["binary_sensor"]:
|
||||
await binary_sensor.async_remove()
|
||||
return True
|
||||
|
||||
|
||||
class AlexaContact(CoordinatorEntity, BinarySensorEntity):
|
||||
"""A contact sensor controlled by an Echo."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
|
||||
def __init__(self, coordinator: CoordinatorEntity, details: dict):
|
||||
"""Initialize alexa contact sensor.
|
||||
|
||||
Args
|
||||
coordinator (CoordinatorEntity): Coordinator
|
||||
details (dict): Details dictionary
|
||||
|
||||
"""
|
||||
super().__init__(coordinator)
|
||||
self.alexa_entity_id = details["id"]
|
||||
self._name = details["name"]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return unique id."""
|
||||
return self.alexa_entity_id
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return whether on."""
|
||||
detection = parse_detection_state_from_coordinator(
|
||||
self.coordinator, self.alexa_entity_id
|
||||
)
|
||||
|
||||
return detection == "DETECTED" if detection is not None else None
|
||||
|
||||
@property
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return assumed state."""
|
||||
last_refresh_success = (
|
||||
self.coordinator.data and self.alexa_entity_id in self.coordinator.data
|
||||
)
|
||||
return not last_refresh_success
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Support to interface with Alexa Devices.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
)
|
||||
|
||||
PROJECT_URL = "https://github.com/alandtse/alexa_media_player/"
|
||||
ISSUE_URL = f"{PROJECT_URL}issues"
|
||||
NOTIFY_URL = f"{PROJECT_URL}wiki/Configuration%3A-Notification-Component#use-the-notifyalexa_media-service"
|
||||
|
||||
DOMAIN = "alexa_media"
|
||||
DATA_ALEXAMEDIA = "alexa_media"
|
||||
|
||||
PLAY_SCAN_INTERVAL = 20
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
MIN_TIME_BETWEEN_SCANS = SCAN_INTERVAL
|
||||
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
|
||||
|
||||
ALEXA_COMPONENTS = [
|
||||
"media_player",
|
||||
]
|
||||
DEPENDENT_ALEXA_COMPONENTS = [
|
||||
"notify",
|
||||
"switch",
|
||||
"sensor",
|
||||
"alarm_control_panel",
|
||||
"light",
|
||||
"binary_sensor",
|
||||
]
|
||||
|
||||
HTTP_COOKIE_HEADER = "# HTTP Cookie File"
|
||||
CONF_ACCOUNTS = "accounts"
|
||||
CONF_DEBUG = "debug"
|
||||
CONF_HASS_URL = "hass_url"
|
||||
CONF_INCLUDE_DEVICES = "include_devices"
|
||||
CONF_EXCLUDE_DEVICES = "exclude_devices"
|
||||
CONF_QUEUE_DELAY = "queue_delay"
|
||||
CONF_PUBLIC_URL = "public_url"
|
||||
CONF_EXTENDED_ENTITY_DISCOVERY = "extended_entity_discovery"
|
||||
CONF_SECURITYCODE = "securitycode"
|
||||
CONF_OTPSECRET = "otp_secret"
|
||||
CONF_PROXY = "proxy"
|
||||
CONF_PROXY_WARNING = "proxy_warning"
|
||||
CONF_SCAN_INTERVAL = (
|
||||
"scan_interval" # local definition; HA's CONF_SCAN_INTERVAL is deprecated
|
||||
)
|
||||
CONF_TOTP_REGISTER = "registered"
|
||||
CONF_OAUTH = "oauth"
|
||||
DATA_LISTENER = "listener"
|
||||
|
||||
EXCEPTION_TEMPLATE = "An exception of type {0} occurred. Arguments:\n{1!r}"
|
||||
|
||||
DEFAULT_DEBUG = False
|
||||
DEFAULT_EXTENDED_ENTITY_DISCOVERY = False
|
||||
DEFAULT_HASS_URL = "http://homeassistant.local:8123"
|
||||
DEFAULT_PUBLIC_URL = ""
|
||||
DEFAULT_QUEUE_DELAY = 1.5
|
||||
DEFAULT_SCAN_INTERVAL = 60
|
||||
|
||||
EPOCH_MS_THRESHOLD = 10_000_000_000
|
||||
|
||||
# Service name constants used by services.py SERVICE_DEFS
|
||||
SERVICE_UPDATE_LAST_CALLED = "update_last_called"
|
||||
SERVICE_RESTORE_VOLUME = "restore_volume"
|
||||
SERVICE_GET_HISTORY_RECORDS = "get_history_records"
|
||||
SERVICE_FORCE_LOGOUT = "force_logout"
|
||||
SERVICE_ENABLE_NETWORK_DISCOVERY = "enable_network_discovery"
|
||||
|
||||
# Backoff durations for the last-called probe worker
|
||||
LAST_CALLED_429_BACKOFF_INITIAL_S = 30.0
|
||||
LAST_CALLED_429_BACKOFF_MAX_S = 15 * 60.0
|
||||
LAST_CALLED_CONN_BACKOFF_S = 10.0
|
||||
LAST_CALLED_LOGIN_BACKOFF_S = 30.0
|
||||
|
||||
# Tuning constants for the per-account last-called probe worker
|
||||
LAST_CALLED_DEBOUNCE_S = 3.5 # coalesce bursty pushes, but stay snappy
|
||||
LAST_CALLED_RETRY_DELAY_S = 4.0 # wider retry cadence for delayed routine history
|
||||
LAST_CALLED_RETRY_LIMIT = 2 # total attempts = 1 + retries (3 attempts)
|
||||
LAST_CALLED_STALE_FUDGE_MS = 5_000 # allow some clock/ordering jitter
|
||||
LAST_CALLED_SUCCESS_PACE_S = 4.0 # post-success pacing to avoid hammering
|
||||
LAST_CALLED_LOOKBACK_MS = 60_000
|
||||
LAST_CALLED_ITEMS = 10
|
||||
LAST_CALLED_COALESCE_WINDOW_MS = 2000
|
||||
|
||||
# Tuning constants for notification retries
|
||||
NOTIFICATION_COOLDOWN = 60
|
||||
NOTIFY_REFRESH_BACKOFF = 15.0
|
||||
NOTIFY_REFRESH_MAX_RETRIES = 3
|
||||
|
||||
# push-health magic numbers
|
||||
HTTP2_ERROR_THRESHOLD = 5
|
||||
LAST_PUSH_INACTIVITY_SECONDS = 600.0
|
||||
LAST_PING_MAX_AGE_SECONDS = 900.0
|
||||
|
||||
RECURRING_PATTERN = {
|
||||
None: "Never Repeat",
|
||||
"P1D": "Every day",
|
||||
"P1M": "Every month",
|
||||
"XXXX-WE": "Weekends",
|
||||
"XXXX-WD": "Weekdays",
|
||||
"XXXX-WXX-1": "Every Monday",
|
||||
"XXXX-WXX-2": "Every Tuesday",
|
||||
"XXXX-WXX-3": "Every Wednesday",
|
||||
"XXXX-WXX-4": "Every Thursday",
|
||||
"XXXX-WXX-5": "Every Friday",
|
||||
"XXXX-WXX-6": "Every Saturday",
|
||||
"XXXX-WXX-7": "Every Sunday",
|
||||
}
|
||||
|
||||
RECURRING_DAY = {
|
||||
"MO": 1,
|
||||
"TU": 2,
|
||||
"WE": 3,
|
||||
"TH": 4,
|
||||
"FR": 5,
|
||||
"SA": 6,
|
||||
"SU": 7,
|
||||
}
|
||||
RECURRING_PATTERN_ISO_SET = {
|
||||
None: {},
|
||||
"P1D": {1, 2, 3, 4, 5, 6, 7},
|
||||
"XXXX-WE": {6, 7},
|
||||
"XXXX-WD": {1, 2, 3, 4, 5},
|
||||
"XXXX-WXX-1": {1},
|
||||
"XXXX-WXX-2": {2},
|
||||
"XXXX-WXX-3": {3},
|
||||
"XXXX-WXX-4": {4},
|
||||
"XXXX-WXX-5": {5},
|
||||
"XXXX-WXX-6": {6},
|
||||
"XXXX-WXX-7": {7},
|
||||
}
|
||||
|
||||
ATTR_MESSAGE = "message"
|
||||
ATTR_EMAIL = "email"
|
||||
ATTR_ENTITY_ID = "entity_id"
|
||||
ATTR_NUM_ENTRIES = "entries"
|
||||
COMMON_BUCKET_COUNTS = (
|
||||
"accounts",
|
||||
"devices",
|
||||
"media_players",
|
||||
"players",
|
||||
"notifications",
|
||||
"entities",
|
||||
)
|
||||
COMMON_DIAGNOSTIC_BUCKETS = (
|
||||
"account",
|
||||
"accounts",
|
||||
"login",
|
||||
"logins",
|
||||
"session",
|
||||
"sessions",
|
||||
)
|
||||
COMMON_DIAGNOSTIC_NAMES = (
|
||||
"name",
|
||||
"deviceName",
|
||||
"accountName",
|
||||
"friendlyName",
|
||||
"title",
|
||||
)
|
||||
DEVICE_PLAYER_BUCKETS = ("devices", "media_players", "players")
|
||||
TO_REDACT: set[str] = {
|
||||
"email",
|
||||
"password",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"token",
|
||||
"csrf",
|
||||
"cookie",
|
||||
"cookies",
|
||||
"session",
|
||||
"sessionid",
|
||||
"macDms",
|
||||
"mac_dms",
|
||||
"otp_secret",
|
||||
"authorization_code",
|
||||
"securitycode",
|
||||
"code_verifier",
|
||||
"adp_token",
|
||||
"device_private_key",
|
||||
"customerId",
|
||||
}
|
||||
STREAMING_ERROR_MESSAGE = (
|
||||
"Sorry, direct music streaming isn't supported. "
|
||||
"This limitation is set by Amazon, and not by Alexa-Media-Player, Music-Assistant, nor Home-Assistant."
|
||||
)
|
||||
PUBLIC_URL_ERROR_MESSAGE = (
|
||||
"To send TTS, please set the public URL in integration configuration."
|
||||
)
|
||||
STARTUP_MESSAGE = """
|
||||
{name} Version Info
|
||||
{DOMAIN}: v{version}
|
||||
alexapy API: v{alexapy_version}
|
||||
If you have any issues with this custom component, you need to open an issue here: {ISSUE_URL}
|
||||
"""
|
||||
|
||||
AUTH_CALLBACK_PATH = "/auth/alexamedia/callback"
|
||||
AUTH_CALLBACK_NAME = "auth:alexamedia:callback"
|
||||
AUTH_PROXY_PATH = "/auth/alexamedia/proxy"
|
||||
AUTH_PROXY_NAME = "auth:alexamedia:proxy"
|
||||
|
||||
ALEXA_UNIT_CONVERSION = {
|
||||
"Alexa.Unit.Percent": PERCENTAGE,
|
||||
"Alexa.Unit.PartsPerMillion": CONCENTRATION_PARTS_PER_MILLION,
|
||||
"Alexa.Unit.Density.MicroGramsPerCubicMeter": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
ALEXA_ICON_CONVERSION = {
|
||||
"Alexa.AirQuality.CarbonMonoxide": "mdi:molecule-co",
|
||||
"Alexa.AirQuality.Humidity": "mdi:water-percent",
|
||||
"Alexa.AirQuality.IndoorAirQuality": "mdi:numeric",
|
||||
"Alexa.AirQuality.ParticulateMatter": "mdi:blur",
|
||||
"Alexa.AirQuality.VolatileOrganicCompounds": "mdi:air-filter",
|
||||
}
|
||||
ALEXA_ICON_DEFAULT = "mdi:molecule"
|
||||
|
||||
# Device class mapping for air quality sensors
|
||||
# Maps Alexa sensor types to Home Assistant SensorDeviceClass
|
||||
ALEXA_AIR_QUALITY_DEVICE_CLASS = {
|
||||
"Alexa.AirQuality.ParticulateMatter": "pm25",
|
||||
"Alexa.AirQuality.CarbonMonoxide": "carbon_monoxide",
|
||||
"Alexa.AirQuality.IndoorAirQuality": "aqi",
|
||||
"Alexa.AirQuality.VolatileOrganicCompounds": "aqi",
|
||||
"Alexa.AirQuality.Humidity": "humidity",
|
||||
}
|
||||
|
||||
UPLOAD_PATH = "www/alexa_tts"
|
||||
|
||||
# Note: Some of these are likely wrong
|
||||
MODEL_IDS = {
|
||||
"A10A33FOX2NUBK": "Echo Spot (Gen1)",
|
||||
"A10L5JEZTKKCZ8": "Vobot Bunny",
|
||||
"A11QM4H9HGV71H": "Echo Show 5 (Gen3)",
|
||||
"A12GXV8XMS007S": "Fire TV (Gen1)",
|
||||
"A12IZU8NMHSY5U": "Generic Device",
|
||||
"A132LT22WVG6X5": "Samsung Soundbar Q700A",
|
||||
"A13B2WB920IZ7X": "Samsung HW-Q70T Soundbar",
|
||||
"A13W6HQIHKEN3Z": "Echo Auto",
|
||||
"A14ZH95E6SE9Z1": "Bose Home Speaker 300",
|
||||
"A15996VY63BQ2D": "Echo Show 8 (Gen2)",
|
||||
"A15ERDAKK5HQQG": "Sonos",
|
||||
"A15QWUTQ6FSMYX": "Echo Buds (Gen2)",
|
||||
"A16MZVIFVHX6P6": "Generic Echo",
|
||||
"A17LGWINFBUTZZ": "Anker Roav Viva",
|
||||
"A18BI6KPKDOEI4": "Ecobee4",
|
||||
"A18O6U1UQFJ0XK": "Echo Plus (Gen2)",
|
||||
"A18TCD9FP10WJ9": "Orbi Voice",
|
||||
"A18X8OBWBCSLD8": "Samsung Soundbar",
|
||||
"A195TXHV1M5D4A": "Echo Auto",
|
||||
"A1C66CX2XD756O": "Fire Tablet HD",
|
||||
"A1D54LQEG0OXJ2": "Denon Home 250",
|
||||
"A1EIANJ7PNB0Q7": "Echo Show 15 (Gen1)",
|
||||
"A1ENT81UXFMNNO": "Unknown",
|
||||
"A1ETW4IXK2PYBP": "Talk to Alexa",
|
||||
"A1F1F76XIW4DHQ": "Unknown TV",
|
||||
"A1F8D55J0FWDTN": "Fire TV (Toshiba)",
|
||||
"A1H0CMF1XM0ZP4": "Bose SoundTouch 30",
|
||||
"A1J16TEDOYCZTN": "Fire Tablet",
|
||||
"A1JJ0KFC4ZPNJ3": "Echo Input",
|
||||
"A1L4KDRIILU6N9": "Sony Speaker",
|
||||
"A1LOQ8ZHF4G510": "Samsung Soundbar Q990B",
|
||||
"A1M0A9L9HDBID3": "One-Link Safe and Sound",
|
||||
"A1MKGHX5VQBDWX": "Denon Home 150",
|
||||
"A1MUORL8FP149X": "Unknown",
|
||||
"A1N9SW0I0LUX5Y": "Ford/Lincoln Alexa App",
|
||||
"A1NL4BVLQ4L3N3": "Echo Show (Gen1)",
|
||||
"A1NQ0LXWBGVQS9": "2021 Samsung QLED TV",
|
||||
"A1P31Q3MOWSHOD": "Zolo Halo Speaker",
|
||||
"A1P7E7V3FCZKU6": "Fire TV (Gen3)",
|
||||
"A1Q69AKRWLJC0F": "TV",
|
||||
"A1Q7QCGNMXAKYW": "Generic Tablet",
|
||||
"A1QKZ9D0IJY332": "Samsung TV 2020-U",
|
||||
"A1RABVCI4QCIKC": "Echo Dot (Gen3)",
|
||||
"A1RTAM01W29CUP": "Windows App",
|
||||
"A1SCI5MODUBAT1": "Pioneer DMH-W466NEX",
|
||||
"A1TD5Z1R8IWBHA": "Tablet",
|
||||
"A1VGB7MHSIEYFK": "Fire TV Cube Gen3",
|
||||
"A1W2YILXTG9HA7": "Nextbase 522GW Dashcam",
|
||||
"A1W46V57KES4B5": "Cable TV box Brazil",
|
||||
"A1WZKXFLI43K86": "Fire TV Stick MAX",
|
||||
"A1XWJRHALS1REP": "Echo Show 5 (Gen2)",
|
||||
"A1Z88NGR2BK6A2": "Echo Show 8 (Gen1)",
|
||||
"A25EC4GIHFOCSG": "Unrecognized Media Player",
|
||||
"A25OJWHZA1MWNB": "2021 Samsung QLED TV",
|
||||
"A265XOI9586NML": "Fire TV Stick",
|
||||
"A27VEYGQBW3YR5": "Echo Link",
|
||||
"A2A3XFQ1AVYLHZ": "SONY WF-1000XM5",
|
||||
"A2BRQDVMSZD13S": "SURE Universal Remote",
|
||||
"A2C8J6UHV0KFCV": "Alexa Gear",
|
||||
"A2DS1Q2TPDJ48U": "Echo Dot Clock (Gen5)",
|
||||
"A2E0SNTXJVT7WK": "Fire TV (Gen2)",
|
||||
"A2E5N6DMWCW8MZ": "Brilliant Smart Switch",
|
||||
"A2EZ3TS0L1S2KV": "Sonos Beam",
|
||||
"A2GFL5ZMWNE0PX": "Fire TV (Gen3)",
|
||||
"A2H4LV5GIZ1JFT": "Echo Dot Clock (Gen4)",
|
||||
"A2HZENIFNYTXZD": "Facebook Portal",
|
||||
"A2I0SCCU3561Y8": "Samsung Soundbar Q800A",
|
||||
"A2IS7199CJBT71": "TV",
|
||||
"A2IVLV5VM2W81": "Alexa Mobile Voice iOS",
|
||||
"A2J0R2SD7G9LPA": "Lenovo SmartTab M10",
|
||||
"A2JKHJ0PX4J3L3": "Fire TV Cube (Gen2)",
|
||||
"A2LH725P8DQR2A": "Fabriq Riff",
|
||||
"A2LLN0UXRW4N50": "Echo Show 11 (Gen1)",
|
||||
"A2LWARUGJLBYEW": "Fire TV Stick (Gen2)",
|
||||
"A2M35JJZWCQOMZ": "Echo Plus (Gen1)",
|
||||
"A2M4YX06LWP8WI": "Fire Tablet",
|
||||
"A2N49KXGVA18AR": "Fire Tablet HD 10 Plus",
|
||||
"A2OSP3UA4VC85F": "Sonos",
|
||||
"A2R2GLZH1DFYQO": "Zolo Halo Speaker",
|
||||
"A2RU4B77X9R9NZ": "Echo Link Amp",
|
||||
"A2TF17PFR55MTB": "Alexa Mobile Voice Android",
|
||||
"A2TTLILJHVNI9X": "LG TV",
|
||||
"A2U21SRK4QGSE1": "Echo Dot (Gen4)",
|
||||
"A2UONLFQW0PADH": "Echo Show 8 (Gen3)",
|
||||
"A2V9UEGZ82H4KZ": "Fire Tablet HD 10",
|
||||
"A2VAXZ7UNGY4ZH": "Wyze Headphones",
|
||||
"A2WFDCBDEXOXR8": "Bose Soundbar 700",
|
||||
"A2WJ2CM9ARLMRH": "Rivian Electric Vehicle",
|
||||
"A2WN1FJ2HG09UN": "Ultimate Alexa App",
|
||||
"A2X8WT9JELC577": "Ecobee5",
|
||||
"A2XPGY5LRKB9BE": "Fitbit Versa 2",
|
||||
"A2Y04QPFCANLPQ": "Bose QuietComfort 35 II",
|
||||
"A303PJF6ISQ7IC": "Echo Auto",
|
||||
"A30YDR2MK8HMRV": "Echo (Gen3)",
|
||||
"A31DTMEEVDDOIV": "Fire TV Stick Lite",
|
||||
"A324YMIUSWQDGE": "Samsung 8K TV",
|
||||
"A32DDESGESSHZA": "Echo Dot (Gen3)",
|
||||
"A32DOYMUN6DTXA": "Echo Dot (Gen3)",
|
||||
"A339L426Y220I4": "Teufel Radio",
|
||||
"A347G2JC8I4HC7": "Roav Car Charger Pro",
|
||||
"A37CFAHI1O0CXT": "Logitech Blast",
|
||||
"A37M7RU8Z6ZFB": "Garmin Speak",
|
||||
"A37SHHQ3NUL7B5": "Bose Home Speaker 500",
|
||||
"A38949IHXHRQ5P": "Echo Tap",
|
||||
"A38BPK7OW001EX": "Raspberry Alexa",
|
||||
"A38EHHIB10L47V": "Fire Tablet HD 8",
|
||||
"A39BU42XNMN516": "Generic Device",
|
||||
"A3B50IC5QPZPWP": "Polk Command Bar",
|
||||
"A3B5K1G3EITBIF": "Facebook Portal",
|
||||
"A3BRT6REMPQWA8": "Bose Home Speaker 450",
|
||||
"A3BW5ZVFHRCQPO": "BMW Alexa Integration",
|
||||
"A3C9PE6TNYLTCH": "Speaker Group",
|
||||
"A3CY98NH016S5F": "Facebook Portal Mini",
|
||||
"A3D4YURNTARP5K": "Facebook Portal TV",
|
||||
"A3EH2E0YZ30OD6": "Echo Spot (Gen2)",
|
||||
"A3EVMLQTU6WL1W": "Fire TV Stick 4K Max (Gen1)",
|
||||
"A3F1S88NTZZXS9": "Dash Wand",
|
||||
"A3FX4UWTP28V1P": "Echo (Gen3)",
|
||||
"A3GFRGUNIGG1I5": "Samsung TV QN50Q60CAGXZD",
|
||||
"A3HF4YRA2L7XGC": "Fire TV Cube",
|
||||
"A3IYPH06PH1HRA": "Echo Frames",
|
||||
"A3K69RS3EIMXPI": "Hisense Smart TV",
|
||||
"A3KULB3NQN7Z1F": "Unknown TV",
|
||||
"A3L0T0VL9A921N": "Fire Tablet HD 8",
|
||||
"A3NPD82ABCPIDP": "Sonos Beam",
|
||||
"A3QPPX1R9W5RJV": "Fabriq Chorus",
|
||||
"A3QS1XP2U6UJX9": "SONY WF-1000XM4",
|
||||
"A3R9S4ZZECZ6YL": "Fire Tablet HD 10",
|
||||
"A3RBAYBE7VM004": "Echo Studio",
|
||||
"A3RCTOK2V0A4ZG": "LG TV",
|
||||
"A3RMGO6LYLH7YN": "Echo Dot (Gen4)",
|
||||
"A3S5BH2HU6VAYF": "Echo Dot (Gen2)",
|
||||
"A3SSG6GR8UU7SN": "Echo Sub",
|
||||
"A3SSWQ04XYPXBH": "Generic Tablet",
|
||||
"A3TCJ8RTT3NVI7": "Alexa Listens",
|
||||
"A3VRME03NAXFUB": "Echo Flex",
|
||||
"A4ZP7ZC4PI6TO": "Echo Show 5 (Gen1)",
|
||||
"A4ZXE0RM7LQ7A": "Echo Dot (Gen5)",
|
||||
"A52ARKF0HM2T4": "Facebook Portal+",
|
||||
"A6SIQKETF3L2E": "Unknown Device",
|
||||
"A7WXQPH584YP": "Echo (Gen2)",
|
||||
"A81PNL0A63P93": "Home Remote",
|
||||
"A8DM4FYR6D3HT": "TV",
|
||||
"AA1IN44SS3X6O": "Ecobee Thermostat Premium",
|
||||
"AB72C64C86AW2": "Echo (Gen1)",
|
||||
"ABJ2EHL7HQT4L": "Unknown Amplifier",
|
||||
"ADVBD696BHNV5": "Fire TV Stick (Gen1)",
|
||||
"AE7X7Z227NFNS": "HiMirror Mini",
|
||||
"AF473ZSOIRKFJ": "Onkyo VC-PX30",
|
||||
"AFF50AL5E3DIU": "Fire TV (Insignia)",
|
||||
"AFF5OAL5E3DIU": "Fire TV",
|
||||
"AGHZIK8D6X7QR": "Fire TV",
|
||||
"AHJYKVA63YCAQ": "Sonos",
|
||||
"AIPK7MM90V7TB": "Echo Show 10 (Gen3)",
|
||||
"AKKLQD9FZWWQS": "Jabra Elite",
|
||||
"AKNO1N0KSFN8L": "Echo Dot (Gen1)",
|
||||
"AKO51L5QAQKL2": "Alexa Jams",
|
||||
"AKPGW064GI9HE": "Fire TV Stick 4K (Gen3)",
|
||||
"ALCIV0P5M8TZ0": "Samsung Soundbar S800B",
|
||||
"ALT9P69K6LORD": "Echo Auto",
|
||||
"AMCZ48H33RCDF": "Samsung HW-Q910B 9.1.2 ch Soundbar",
|
||||
"AN630UQPG2CA4": "Fire TV (Toshiba)",
|
||||
"AO6HHP9UE6EOF": "Unknown Media Device",
|
||||
"AP1F6KUH00XPV": "Stereo/Subwoofer Pair",
|
||||
"AP4RS91ZQ0OOI": "Fire TV (Toshiba)",
|
||||
"APHEAY6LX7T13": "Samsung Smart Refrigerator",
|
||||
"AQCGW9PSYWRF": "TV",
|
||||
"AR6X0XNIME80V": "Unknown TV",
|
||||
"ASQZWP4GPYUT7": "Echo Pop",
|
||||
"ATNLRCEBX3W4P": "Generic Tablet",
|
||||
"AUPUQSVCVHXP0": "Ecobee Switch+",
|
||||
"AVD3HM0HOJAAL": "Sonos",
|
||||
"AVE5HX13UR5NO": "Logitech Zero Touch",
|
||||
"AVN2TMX8MU2YM": "Bose Home Speaker 500",
|
||||
"AVU7CPPF2ZRAS": "Fire Tablet HD 8",
|
||||
"AWZZ5CVHX2CD": "Echo Show (Gen2)",
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Optimized DataUpdateCoordinator for Alexa Media Player.
|
||||
|
||||
Optimizations:
|
||||
- Debouncer for request coalescing
|
||||
- Type-safe runtime data integration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .runtime_data import AlexaRuntimeData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Debounce cooldown in seconds - prevents API hammering during push bursts
|
||||
REQUEST_REFRESH_DEBOUNCE_COOLDOWN = 1.5
|
||||
|
||||
|
||||
class AlexaMediaCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for Alexa Media Player.
|
||||
|
||||
Features:
|
||||
- Debounced refresh requests to avoid API hammering
|
||||
- Type-safe integration with runtime_data
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
runtime_data: AlexaRuntimeData | None,
|
||||
update_method: Callable,
|
||||
scan_interval: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
runtime_data: Runtime data for this config entry
|
||||
update_method: Async method to fetch data
|
||||
scan_interval: Polling interval in seconds (default: SCAN_INTERVAL)
|
||||
"""
|
||||
self.runtime_data = runtime_data
|
||||
self._scan_interval = scan_interval or SCAN_INTERVAL.total_seconds()
|
||||
|
||||
# Calculate update interval based on HTTP2 status
|
||||
http2_enabled = runtime_data.http2 is not None if runtime_data else False
|
||||
update_interval = timedelta(
|
||||
seconds=self._scan_interval * 10 if http2_enabled else self._scan_interval
|
||||
)
|
||||
|
||||
# Initialize debouncer for request coalescing
|
||||
# This prevents multiple rapid refresh requests from hammering the API
|
||||
debouncer = Debouncer(
|
||||
hass,
|
||||
_LOGGER,
|
||||
cooldown=REQUEST_REFRESH_DEBOUNCE_COOLDOWN,
|
||||
immediate=True,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
config_entry=(
|
||||
runtime_data.config_entry
|
||||
if runtime_data and runtime_data.config_entry
|
||||
else None
|
||||
),
|
||||
update_method=update_method,
|
||||
update_interval=update_interval,
|
||||
request_refresh_debouncer=debouncer,
|
||||
)
|
||||
|
||||
def set_http2_status(self, enabled: bool) -> None:
|
||||
"""Update polling interval based on HTTP2 connection status.
|
||||
|
||||
When HTTP2 is enabled, we can poll less frequently since we get push updates.
|
||||
"""
|
||||
new_interval = timedelta(
|
||||
seconds=self._scan_interval * 10 if enabled else self._scan_interval
|
||||
)
|
||||
if self.update_interval != new_interval:
|
||||
self.update_interval = new_interval
|
||||
_LOGGER.debug(
|
||||
"Updated polling interval: %s (HTTP2: %s)",
|
||||
new_interval,
|
||||
enabled,
|
||||
)
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Diagnostics support for Alexa Media Player."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import fields, is_dataclass
|
||||
from datetime import datetime
|
||||
from itertools import islice
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.redact import async_redact_data
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
COMMON_BUCKET_COUNTS,
|
||||
COMMON_DIAGNOSTIC_BUCKETS,
|
||||
COMMON_DIAGNOSTIC_NAMES,
|
||||
DEVICE_PLAYER_BUCKETS,
|
||||
DOMAIN,
|
||||
TO_REDACT,
|
||||
)
|
||||
|
||||
|
||||
# --------------------
|
||||
# Local Functions
|
||||
# --------------------
|
||||
def _safe_dt(val: Any) -> str | None:
|
||||
"""Serialize datetimes safely for JSON diagnostics."""
|
||||
if isinstance(val, datetime):
|
||||
return val.isoformat()
|
||||
return None
|
||||
|
||||
|
||||
def _maybe_len(val: Any) -> int | None:
|
||||
"""Return the length of common container types or None if not applicable."""
|
||||
if isinstance(val, (list, tuple, dict, set)):
|
||||
return len(val)
|
||||
return None
|
||||
|
||||
|
||||
def _maybe_keys(val: Any, limit: int = 50) -> list[str] | None:
|
||||
"""Return a sanitized sample of mapping keys for diagnostics.
|
||||
|
||||
If ``val`` is a mapping, return up to ``limit`` obfuscated keys to provide
|
||||
structural insight without exposing sensitive data. Email-like keys are
|
||||
redacted when possible; otherwise keys are shortened to a non-identifying
|
||||
form. Returns ``None`` if ``val`` is not a mapping or keys cannot be read.
|
||||
"""
|
||||
|
||||
if isinstance(val, Mapping):
|
||||
try:
|
||||
# Sample up to `limit` keys to keep diagnostics small.
|
||||
def _safe_key(k: Any) -> str:
|
||||
s = str(k)
|
||||
# Emails/titles/tokens sometimes appear as keys in AMP structures.
|
||||
if re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", s):
|
||||
try:
|
||||
from alexapy import ( # pylint: disable=import-outside-toplevel
|
||||
hide_email,
|
||||
)
|
||||
|
||||
return hide_email(s)
|
||||
except (ImportError, AttributeError, TypeError, ValueError):
|
||||
pass
|
||||
return _obfuscate_identifier(s)
|
||||
|
||||
return sorted(_safe_key(k) for k in islice(val.keys(), limit))
|
||||
except (TypeError, AttributeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sample_names(val: Any, *, limit: int = 5) -> list[str] | None:
|
||||
"""Try to sample human-friendly names from a list/dict of device-like objects."""
|
||||
names: list[str] = []
|
||||
|
||||
def add_name(x: Any) -> None:
|
||||
if isinstance(x, Mapping):
|
||||
for key in COMMON_DIAGNOSTIC_NAMES:
|
||||
v = x.get(key)
|
||||
if isinstance(v, str) and v:
|
||||
names.append(v)
|
||||
return
|
||||
v = getattr(x, "name", None)
|
||||
if isinstance(v, str) and v:
|
||||
names.append(v)
|
||||
|
||||
if isinstance(val, Mapping):
|
||||
for v in islice(val.values(), limit * 2):
|
||||
add_name(v)
|
||||
if len(names) >= limit:
|
||||
break
|
||||
return names[:limit] if names else None
|
||||
|
||||
if isinstance(val, (list, tuple)):
|
||||
for v in val[: limit * 2]:
|
||||
add_name(v)
|
||||
if len(names) >= limit:
|
||||
break
|
||||
return names[:limit] if names else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# --------------------
|
||||
# Coordinator discovery + summary
|
||||
# --------------------
|
||||
def _find_coordinators(obj: Any) -> list[DataUpdateCoordinator]:
|
||||
"""Recursively find DataUpdateCoordinator instances in an object tree."""
|
||||
found: list[DataUpdateCoordinator] = []
|
||||
visited: set[int] = set()
|
||||
|
||||
def walk(x: Any) -> None:
|
||||
obj_id = id(x)
|
||||
if obj_id in visited:
|
||||
return
|
||||
visited.add(obj_id)
|
||||
|
||||
if isinstance(x, DataUpdateCoordinator):
|
||||
found.append(x)
|
||||
return
|
||||
if is_dataclass(x):
|
||||
try:
|
||||
# Walk dataclass attributes directly; asdict() can lose/mangle objects.
|
||||
for f in fields(x):
|
||||
try:
|
||||
walk(getattr(x, f.name))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Skip fields that can't be read safely
|
||||
pass
|
||||
except (TypeError, ValueError):
|
||||
# Fallback: vars() can work for some dataclass/slots variations
|
||||
try:
|
||||
for v in vars(x).values():
|
||||
walk(v)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Ignore attributes that cannot be introspected via vars()
|
||||
pass
|
||||
return
|
||||
if isinstance(x, Mapping):
|
||||
for v in x.values():
|
||||
walk(v)
|
||||
return
|
||||
if isinstance(x, (list, tuple, set)):
|
||||
for v in x:
|
||||
walk(v)
|
||||
return
|
||||
# Ignore everything else.
|
||||
|
||||
walk(obj)
|
||||
return found
|
||||
|
||||
|
||||
def _summarize_coordinator_data(cdata: Any) -> dict:
|
||||
"""
|
||||
Allowlisted summary of coordinator.data.
|
||||
|
||||
Never dump raw coordinator data. Only return counts + small samples.
|
||||
Optimized for AMP: coordinator.data is often a mapping keyed by UUIDs.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
if isinstance(cdata, Mapping):
|
||||
out["data_key_count"] = len(cdata)
|
||||
|
||||
key_sample = list(islice(cdata.keys(), 10))
|
||||
|
||||
out["data_key_types_sample"] = [type(k).__name__ for k in key_sample]
|
||||
|
||||
sample_vals = [type(cdata.get(k)).__name__ for k in key_sample[:3]]
|
||||
if sample_vals:
|
||||
out["data_value_types_sample"] = sample_vals
|
||||
|
||||
# If coordinator.data sometimes contains named buckets (future-proof),
|
||||
# include just counts (but only if those keys actually exist).
|
||||
for key in COMMON_DIAGNOSTIC_BUCKETS:
|
||||
if key in cdata:
|
||||
out[f"{key}_count"] = _maybe_len(cdata.get(key))
|
||||
|
||||
# If AMP ever exposes last_called through coordinator.data, include only safe fields.
|
||||
last_called = cdata.get("last_called")
|
||||
if isinstance(last_called, Mapping):
|
||||
ts = last_called.get("timestamp")
|
||||
out["last_called"] = {
|
||||
"timestamp": _safe_dt(ts) or ts,
|
||||
"summary": last_called.get("summary"),
|
||||
}
|
||||
|
||||
# If there are device/player buckets, sample friendly names (no IDs).
|
||||
for key in DEVICE_PLAYER_BUCKETS:
|
||||
if key in cdata:
|
||||
sample = _sample_names(cdata.get(key))
|
||||
if sample:
|
||||
out[f"{key}_sample_names"] = sample
|
||||
break
|
||||
|
||||
return out
|
||||
|
||||
if isinstance(cdata, (list, tuple)):
|
||||
out["data_len"] = len(cdata)
|
||||
sample = _sample_names(cdata)
|
||||
if sample:
|
||||
out["sample_names"] = sample
|
||||
return out
|
||||
|
||||
if cdata is not None:
|
||||
out["data_type"] = type(cdata).__name__
|
||||
return out
|
||||
|
||||
|
||||
def _summarize_coordinator(coordinator: DataUpdateCoordinator) -> dict:
|
||||
"""Return a safe, compact view of a coordinator."""
|
||||
exc = getattr(coordinator, "last_exception", None)
|
||||
|
||||
data = {
|
||||
"name": getattr(coordinator, "name", None),
|
||||
"last_update_success": getattr(coordinator, "last_update_success", None),
|
||||
"has_exception": exc is not None,
|
||||
"last_exception_type": type(exc).__name__ if exc else None,
|
||||
"update_interval": (
|
||||
str(getattr(coordinator, "update_interval", None))
|
||||
if getattr(coordinator, "update_interval", None) is not None
|
||||
else None
|
||||
),
|
||||
"last_update": _safe_dt(getattr(coordinator, "last_update", None)),
|
||||
}
|
||||
|
||||
try:
|
||||
data["data_summary"] = _summarize_coordinator_data(
|
||||
getattr(coordinator, "data", None)
|
||||
)
|
||||
except (
|
||||
Exception
|
||||
) as exc: # noqa: BLE001 - intentionally broad; diagnostics must not crash
|
||||
data["data_summary_error"] = type(exc).__name__
|
||||
data["data_summary_error_present"] = True
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# --------------------
|
||||
# AMP-specific (non-coordinator) runtime summaries
|
||||
# --------------------
|
||||
def _summarize_amp_entry_runtime(entry_runtime: Any) -> dict:
|
||||
"""
|
||||
Best-effort summary of hass.data[DOMAIN][entry_id] runtime.
|
||||
|
||||
AMP may not store anything here; keep robust.
|
||||
"""
|
||||
out: dict[str, Any] = {"present": entry_runtime is not None}
|
||||
|
||||
if isinstance(entry_runtime, Mapping):
|
||||
out["runtime_type"] = "mapping"
|
||||
out["runtime_keys"] = _maybe_keys(entry_runtime)
|
||||
# Common “bucket” counts if they happen to exist.
|
||||
for key in COMMON_BUCKET_COUNTS:
|
||||
if key in entry_runtime:
|
||||
out[f"{key}_count"] = _maybe_len(entry_runtime.get(key))
|
||||
# Small sample of names
|
||||
for key in DEVICE_PLAYER_BUCKETS:
|
||||
if key in entry_runtime:
|
||||
sample = _sample_names(entry_runtime.get(key))
|
||||
if sample:
|
||||
out[f"{key}_sample_names"] = sample
|
||||
break
|
||||
else:
|
||||
if entry_runtime is not None:
|
||||
out["runtime_type"] = type(entry_runtime).__name__
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _obfuscate_identifier(val: Any) -> str:
|
||||
"""Return a shortened, non-identifying representation of a value.
|
||||
|
||||
Non-string, empty, or very short values are fully masked. Longer strings
|
||||
are reduced to a minimal prefix and suffix to aid debugging without
|
||||
exposing the original identifier.
|
||||
"""
|
||||
if not isinstance(val, str) or not val or len(val) <= 4:
|
||||
return "****"
|
||||
return f"{val[:2]}...{val[-2:]}"
|
||||
|
||||
|
||||
def _obfuscate_title_with_email(title: str | None, email: str | None) -> str | None:
|
||||
"""Obfuscate email in config entry title using the same mechanism as AMP logs."""
|
||||
if not title or not email:
|
||||
return title
|
||||
|
||||
try:
|
||||
# Lazy import to keep diagnostics import cheap
|
||||
from alexapy import hide_email # pylint: disable=import-outside-toplevel
|
||||
|
||||
redacted = hide_email(email)
|
||||
except (ImportError, AttributeError, TypeError, ValueError):
|
||||
redacted = _obfuscate_identifier(email)
|
||||
|
||||
return title.replace(email, redacted)
|
||||
|
||||
|
||||
def _get_safe_config_entry_title(config_entry: ConfigEntry) -> str | None:
|
||||
"""Get obfuscated config entry title."""
|
||||
email = config_entry.data.get("email")
|
||||
return _obfuscate_title_with_email(config_entry.title, email)
|
||||
|
||||
|
||||
def _summarize_amp_domain(domain_data: Any, config_entry: ConfigEntry) -> dict:
|
||||
"""
|
||||
Best-effort summary of hass.data[DOMAIN] for AMP.
|
||||
|
||||
AMP historically stores account/login state in custom structures, not always
|
||||
keyed by entry_id, and often not using DataUpdateCoordinator.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
out["domain_data_present"] = domain_data is not None
|
||||
out["domain_data_type"] = (
|
||||
type(domain_data).__name__ if domain_data is not None else None
|
||||
)
|
||||
|
||||
if not isinstance(domain_data, Mapping):
|
||||
return out
|
||||
|
||||
out["domain_keys"] = _maybe_keys(domain_data)
|
||||
|
||||
# Try a few common/likely buckets without dumping contents.
|
||||
# NOTE: We deliberately avoid copying values; only report counts/types/samples.
|
||||
for key in COMMON_DIAGNOSTIC_BUCKETS:
|
||||
if key in domain_data:
|
||||
val = domain_data.get(key)
|
||||
out[f"{key}_type"] = type(val).__name__
|
||||
out[f"{key}_len"] = _maybe_len(val)
|
||||
sample = _sample_names(val)
|
||||
if sample:
|
||||
out[f"{key}_sample_names"] = sample
|
||||
|
||||
# Try to locate the specific account blob by email/title if present.
|
||||
# The config entry title often contains "email - url". We'll only use it to
|
||||
# match keys; we won't add the email to diagnostics (redaction will remove it).
|
||||
raw_title = config_entry.title or ""
|
||||
email = config_entry.data.get("email")
|
||||
out["entry_title_hint"] = _obfuscate_title_with_email(raw_title, email)
|
||||
# Some integrations store per-entry runtime keyed by entry_id *or* by title/email.
|
||||
# Report whether those keys exist.
|
||||
out["has_entry_id_key"] = config_entry.entry_id in domain_data
|
||||
out["has_title_key"] = raw_title in domain_data if raw_title else False
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# --------------------
|
||||
# Diagnostics entry points
|
||||
# --------------------
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict:
|
||||
"""Return diagnostics for a config entry."""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
safe_title = _get_safe_config_entry_title(config_entry)
|
||||
|
||||
# AMP currently doesn't store runtime under entry_id.
|
||||
# This adds future-proofing for if and when it does.
|
||||
entry_runtime = None
|
||||
if isinstance(domain_data, Mapping):
|
||||
entry_runtime = domain_data.get(config_entry.entry_id)
|
||||
|
||||
# Coordinator discovery:
|
||||
# 1) Try under entry_runtime (best practice)
|
||||
# 2) If none found and domain_data is a mapping, try domain_data as a whole
|
||||
coordinators: list[DataUpdateCoordinator] = []
|
||||
searched: list[str] = []
|
||||
|
||||
if entry_runtime is not None:
|
||||
searched.append("hass.data[DOMAIN][entry_id]")
|
||||
coordinators = _find_coordinators(entry_runtime)
|
||||
|
||||
if not coordinators and isinstance(domain_data, Mapping):
|
||||
searched.append("hass.data[DOMAIN]")
|
||||
coordinators = _find_coordinators(domain_data)
|
||||
|
||||
coordinator_summaries = [_summarize_coordinator(c) for c in coordinators]
|
||||
|
||||
data: dict = {
|
||||
"entry": {
|
||||
"entry_id": config_entry.entry_id,
|
||||
"title": safe_title,
|
||||
"domain": config_entry.domain,
|
||||
"version": config_entry.version,
|
||||
"minor_version": config_entry.minor_version,
|
||||
},
|
||||
# Include config + options; sensitive values are redacted below.
|
||||
"data": dict(config_entry.data),
|
||||
"options": dict(config_entry.options),
|
||||
"account": {
|
||||
"searched_for_coordinators_in": searched,
|
||||
"coordinator_count": len(coordinator_summaries),
|
||||
"coordinators": coordinator_summaries,
|
||||
# AMP-specific summaries (useful when coordinator_count == 0)
|
||||
"amp_entry_runtime_summary": _summarize_amp_entry_runtime(entry_runtime),
|
||||
"amp_domain_summary": _summarize_amp_domain(domain_data, config_entry),
|
||||
},
|
||||
}
|
||||
|
||||
return async_redact_data(data, TO_REDACT)
|
||||
|
||||
|
||||
async def async_get_device_diagnostics(
|
||||
_hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry
|
||||
) -> dict:
|
||||
"""Return diagnostics for a specific device."""
|
||||
safe_title = _get_safe_config_entry_title(config_entry)
|
||||
|
||||
try:
|
||||
# Lazy import to keep diagnostics import cheap
|
||||
from alexapy import hide_serial # pylint: disable=import-outside-toplevel
|
||||
|
||||
safe_serial = hide_serial(device.serial_number)
|
||||
except (ImportError, AttributeError, TypeError, ValueError):
|
||||
safe_serial = _obfuscate_identifier(device.serial_number)
|
||||
|
||||
data: dict = {
|
||||
"device": {
|
||||
"id": _obfuscate_identifier(device.id),
|
||||
"name": device.name,
|
||||
"name_by_user": device.name_by_user,
|
||||
"manufacturer": device.manufacturer,
|
||||
"model": device.model,
|
||||
"sw_version": device.sw_version,
|
||||
"serial_number": safe_serial,
|
||||
"identifiers": sorted(
|
||||
(domain, _obfuscate_identifier(value))
|
||||
for domain, value in device.identifiers
|
||||
),
|
||||
"via_device_id": _obfuscate_identifier(device.via_device_id),
|
||||
},
|
||||
"config_entry": {
|
||||
"entry_id": config_entry.entry_id,
|
||||
"title": safe_title,
|
||||
},
|
||||
}
|
||||
|
||||
return async_redact_data(data, TO_REDACT)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Alexa Media Exceptions"""
|
||||
|
||||
|
||||
class EmptyDataException(Exception):
|
||||
"""Empty data exception"""
|
||||
|
||||
|
||||
class ForbiddenException(Exception):
|
||||
"""Forbidden exception"""
|
||||
|
||||
|
||||
class LoginForbiddenException(Exception):
|
||||
"""Login forbidden exception"""
|
||||
|
||||
|
||||
class LoginInvalidException(Exception):
|
||||
"""Invalid login exception"""
|
||||
|
||||
def __init__(self, attempts_remaining):
|
||||
self.attempts_remaining = attempts_remaining
|
||||
super().__init__(
|
||||
f"Invalid login credentials. {attempts_remaining} attempts remaining."
|
||||
)
|
||||
|
||||
|
||||
class TimeoutException(Exception):
|
||||
"""Timeout exception"""
|
||||
|
||||
def __init__(self, message=""):
|
||||
super().__init__(f"Timeout exception: {message}")
|
||||
|
||||
|
||||
class UnexpectedApiException(Exception):
|
||||
"""Unexpected API exception"""
|
||||
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Helper functions for Alexa Media Player.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any, Callable, Optional, TypeVar, overload
|
||||
|
||||
from alexapy import AlexapyLoginCloseRequested, AlexapyLoginError, hide_email
|
||||
from alexapy.alexalogin import AlexaLogin
|
||||
from dictor import dictor
|
||||
from homeassistant.const import CONF_EMAIL, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConditionErrorMessage
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||
import wrapt
|
||||
|
||||
from .const import DATA_ALEXAMEDIA, EXCEPTION_TEMPLATE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
ArgType = TypeVar("ArgType")
|
||||
|
||||
|
||||
def _norm_filter_token(value: Any) -> str | None:
|
||||
"""Normalize a single filter token for reliable matching."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s.casefold()
|
||||
|
||||
|
||||
def _coerce_filter(value: Any) -> set[str]:
|
||||
"""Coerce include/exclude filter input into a normalized set[str].
|
||||
|
||||
Accepts:
|
||||
- None / empty -> empty set
|
||||
- comma-separated str -> split on commas
|
||||
- list/set/tuple -> per-item normalization
|
||||
- anything else -> single token (best effort)
|
||||
"""
|
||||
if not value:
|
||||
return set()
|
||||
|
||||
# Legacy/back-compat: allow comma-separated string
|
||||
if isinstance(value, str):
|
||||
out = set()
|
||||
for part in value.split(","):
|
||||
token = _norm_filter_token(part)
|
||||
if token:
|
||||
out.add(token)
|
||||
return out
|
||||
|
||||
if isinstance(value, (list, set, tuple)):
|
||||
out = set()
|
||||
for v in value:
|
||||
token = _norm_filter_token(v)
|
||||
if token:
|
||||
out.add(token)
|
||||
return out
|
||||
|
||||
token = _norm_filter_token(value)
|
||||
return {token} if token else set()
|
||||
|
||||
|
||||
async def add_devices(
|
||||
account: str,
|
||||
devices: list[Entity],
|
||||
add_devices_callback: Callable[[list[Entity], bool], None],
|
||||
include_filter: str | list[str] | set[str] | tuple[str, ...] | None = None,
|
||||
exclude_filter: str | list[str] | set[str] | tuple[str, ...] | None = None,
|
||||
) -> bool:
|
||||
"""Add devices using add_devices_callback."""
|
||||
include_filter_set = _coerce_filter(include_filter)
|
||||
exclude_filter_set = _coerce_filter(exclude_filter)
|
||||
if include_filter_set:
|
||||
_LOGGER.debug(
|
||||
"%s: include_filter_set: %s",
|
||||
account,
|
||||
include_filter_set,
|
||||
)
|
||||
if exclude_filter_set:
|
||||
_LOGGER.debug(
|
||||
"%s: exclude_filter_set: %s",
|
||||
account,
|
||||
exclude_filter_set,
|
||||
)
|
||||
|
||||
def _device_name(dev: Entity) -> str | None:
|
||||
"""Best-effort name before entity_id is assigned.
|
||||
|
||||
For AMP switches, reconstruct the legacy "<device> <suffix> switch"
|
||||
name only if those attributes were explicitly set.
|
||||
"""
|
||||
|
||||
# First prefer explicitly set name attributes (works for tests + most entities)
|
||||
name = (
|
||||
getattr(dev, "name", None)
|
||||
or getattr(dev, "_attr_name", None)
|
||||
or getattr(dev, "_name", None)
|
||||
or getattr(dev, "_device_name", None)
|
||||
or getattr(dev, "_friendly_name", None)
|
||||
)
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Only attempt switch reconstruction if attributes were explicitly defined
|
||||
# (avoids MagicMock auto-attribute trap in tests)
|
||||
dev_dict = getattr(dev, "__dict__", {})
|
||||
|
||||
client = dev_dict.get("_client")
|
||||
suffix = dev_dict.get("_unique_id_suffix")
|
||||
|
||||
if client and suffix:
|
||||
client_dict = getattr(client, "__dict__", {})
|
||||
base = (
|
||||
client_dict.get("name")
|
||||
or client_dict.get("_attr_name")
|
||||
or client_dict.get("_name")
|
||||
or client_dict.get("_device_name")
|
||||
)
|
||||
if base:
|
||||
return f"{base} {suffix} switch"
|
||||
|
||||
return None
|
||||
|
||||
def _device_label(dev: Entity) -> str:
|
||||
"""Return a compact, stable identifier for logging."""
|
||||
name = _device_name(dev)
|
||||
entity_id = getattr(dev, "entity_id", None) # often not set yet
|
||||
dev_type = type(dev).__name__
|
||||
|
||||
if name and entity_id:
|
||||
return f"{name} ({dev_type}, {entity_id})"
|
||||
if name:
|
||||
return f"{name} ({dev_type})"
|
||||
return f"<unnamed> ({dev_type})"
|
||||
|
||||
def _devices_preview(devs: list[Entity]) -> str:
|
||||
max_items = 8
|
||||
labels = [_device_label(d) for d in devs[:max_items]]
|
||||
suffix = f" …(+{len(devs) - max_items} more)" if len(devs) > max_items else ""
|
||||
return ", ".join(labels) + suffix
|
||||
|
||||
def _filter_devices(
|
||||
devs: list[Entity],
|
||||
include_set: set[str],
|
||||
exclude_set: set[str],
|
||||
) -> list[Entity]:
|
||||
selected: list[Entity] = []
|
||||
|
||||
include_mode = bool(include_set)
|
||||
if include_mode and exclude_set:
|
||||
_LOGGER.debug(
|
||||
"%s: include_devices set; ignoring exclude_devices per documented precedence",
|
||||
account,
|
||||
)
|
||||
|
||||
for dev in devs:
|
||||
dev_name = _norm_filter_token(_device_name(dev))
|
||||
|
||||
# INCLUDE MODE: only include explicitly listed names
|
||||
if include_mode:
|
||||
if dev_name and dev_name in include_set:
|
||||
selected.append(dev)
|
||||
else:
|
||||
if not dev_name:
|
||||
_LOGGER.debug(
|
||||
"%s: Not including device (no name yet): %s",
|
||||
account,
|
||||
_device_label(dev),
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s: Not including device: %s (match key=%r)",
|
||||
account,
|
||||
_device_label(dev),
|
||||
dev_name,
|
||||
)
|
||||
continue
|
||||
|
||||
# EXCLUDE MODE: exclude listed names
|
||||
if exclude_set and dev_name and dev_name in exclude_set:
|
||||
_LOGGER.debug(
|
||||
"%s: Excluding device: %s (match key=%r)",
|
||||
account,
|
||||
_device_label(dev),
|
||||
dev_name,
|
||||
)
|
||||
continue
|
||||
|
||||
selected.append(dev)
|
||||
|
||||
return selected
|
||||
|
||||
devices = _filter_devices(devices, include_filter_set, exclude_filter_set)
|
||||
if not devices:
|
||||
return True
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Adding %d device(s): %s",
|
||||
account,
|
||||
len(devices),
|
||||
_devices_preview(devices),
|
||||
)
|
||||
|
||||
try:
|
||||
add_devices_callback(devices, False)
|
||||
except ConditionErrorMessage as exception_:
|
||||
message: str = exception_.message
|
||||
if message.startswith("Entity id already exists"):
|
||||
_LOGGER.debug("%s: Device already added: %s", account, message)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s: Unable to add %d device(s): %s",
|
||||
account,
|
||||
len(devices),
|
||||
message,
|
||||
)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.debug(
|
||||
"%s: Unable to add %d device(s): %s",
|
||||
account,
|
||||
len(devices),
|
||||
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
||||
)
|
||||
else:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def retry_async(
|
||||
limit: int = 5, delay: float = 1, catch_exceptions: bool = True
|
||||
) -> Callable:
|
||||
"""Wrap function with retry logic.
|
||||
|
||||
The function will retry until true or the limit is reached. It will delay
|
||||
for the period of time specified exponentially increasing the delay.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
limit : int
|
||||
The max number of retries.
|
||||
delay : float
|
||||
The delay in seconds between retries.
|
||||
catch_exceptions : bool
|
||||
Whether exceptions should be caught and treated as failures or thrown.
|
||||
|
||||
Returns
|
||||
-------
|
||||
def
|
||||
Wrapped function.
|
||||
|
||||
"""
|
||||
|
||||
def wrap(func) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
_LOGGER.debug(
|
||||
"%s.%s: Trying with limit %s delay %s catch_exceptions %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
limit,
|
||||
delay,
|
||||
catch_exceptions,
|
||||
)
|
||||
retries: int = 0
|
||||
result: bool = False
|
||||
next_try: int = 0
|
||||
while not result and retries < limit:
|
||||
if retries != 0:
|
||||
next_try = delay * 2**retries
|
||||
await asyncio.sleep(next_try)
|
||||
retries += 1
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
if not catch_exceptions:
|
||||
raise
|
||||
_LOGGER.debug(
|
||||
"%s.%s: failure caught due to exception: %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s.%s: Try: %s/%s after waiting %s seconds result: %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
retries,
|
||||
limit,
|
||||
next_try,
|
||||
result,
|
||||
)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
@wrapt.decorator
|
||||
async def _catch_login_errors(func, instance, args, kwargs) -> Any:
|
||||
"""Detect AlexapyLoginError and attempt relogin."""
|
||||
|
||||
result = None
|
||||
if instance is None and args:
|
||||
instance = args[0]
|
||||
if hasattr(instance, "check_login_changes"):
|
||||
# _LOGGER.debug(
|
||||
# "%s checking for login changes", instance,
|
||||
# )
|
||||
instance.check_login_changes()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
except AlexapyLoginCloseRequested:
|
||||
_LOGGER.debug(
|
||||
"%s.%s: Ignoring attempt to access Alexa after HA shutdown",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
)
|
||||
return None
|
||||
except AlexapyLoginError as ex:
|
||||
login = None
|
||||
email = None
|
||||
all_args = list(args) + list(kwargs.values())
|
||||
# _LOGGER.debug("Func %s instance %s %s %s", func, instance, args, kwargs)
|
||||
if instance:
|
||||
if hasattr(instance, "_login"):
|
||||
login = instance._login # pylint: disable=protected-access
|
||||
hass = instance.hass
|
||||
else:
|
||||
for arg in all_args:
|
||||
_LOGGER.debug("Checking %s", arg)
|
||||
|
||||
if isinstance(arg, AlexaLogin):
|
||||
login = arg
|
||||
break
|
||||
if hasattr(arg, "_login"):
|
||||
login = instance._login
|
||||
hass = instance.hass
|
||||
break
|
||||
|
||||
if login:
|
||||
# Try to re-login
|
||||
email = login.email
|
||||
if await login.test_loggedin():
|
||||
_LOGGER.info(
|
||||
"%s.%s: Successful re-login after a login error for %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
hide_email(email),
|
||||
)
|
||||
return None
|
||||
_LOGGER.debug(
|
||||
"%s.%s: detected bad login for %s: %s",
|
||||
func.__module__[func.__module__.find(".") + 1 :],
|
||||
func.__name__,
|
||||
hide_email(email),
|
||||
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
||||
)
|
||||
try:
|
||||
hass
|
||||
except NameError:
|
||||
hass = None
|
||||
report_relogin_required(hass, login, email)
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def report_relogin_required(hass, login, email) -> bool:
|
||||
"""Send message for relogin required."""
|
||||
if hass and login and email:
|
||||
if login.status:
|
||||
_LOGGER.debug(
|
||||
"Reporting need to relogin to %s with %s stats: %s",
|
||||
login.url,
|
||||
hide_email(email),
|
||||
login.stats,
|
||||
)
|
||||
hass.bus.async_fire(
|
||||
"alexa_media_relogin_required",
|
||||
event_data={
|
||||
"email": hide_email(email),
|
||||
"url": login.url,
|
||||
"stats": login.stats,
|
||||
},
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _existing_serials(hass, login_obj) -> list:
|
||||
"""Retrieve existing serial numbers for a given login object."""
|
||||
email: str = login_obj.email
|
||||
if (
|
||||
DATA_ALEXAMEDIA in hass.data
|
||||
and "accounts" in hass.data[DATA_ALEXAMEDIA]
|
||||
and email in hass.data[DATA_ALEXAMEDIA]["accounts"]
|
||||
):
|
||||
existing_serials = list(
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][
|
||||
"media_player"
|
||||
].keys()
|
||||
)
|
||||
device_data = (
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][email]
|
||||
.get("devices", {})
|
||||
.get("media_player", {})
|
||||
)
|
||||
for serial in existing_serials[:]:
|
||||
device = device_data.get(serial, {})
|
||||
if "appDeviceList" in device and device["appDeviceList"]:
|
||||
apps = [
|
||||
x["serialNumber"]
|
||||
for x in device["appDeviceList"]
|
||||
if "serialNumber" in x
|
||||
]
|
||||
existing_serials.extend(apps)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"No accounts data found for %s. Skipping serials retrieval.", email
|
||||
)
|
||||
existing_serials = []
|
||||
return existing_serials
|
||||
|
||||
|
||||
async def calculate_uuid(hass, email: str, url: str) -> dict:
|
||||
"""Return uuid and index of email/url.
|
||||
|
||||
Args
|
||||
hass (bool): Hass entity
|
||||
url (str): url for account
|
||||
email (str): email for account
|
||||
|
||||
Returns
|
||||
dict: dictionary with uuid and index
|
||||
|
||||
"""
|
||||
result = {}
|
||||
return_index = 0
|
||||
if hass.config_entries.async_entries(DATA_ALEXAMEDIA):
|
||||
for index, entry in enumerate(
|
||||
hass.config_entries.async_entries(DATA_ALEXAMEDIA)
|
||||
):
|
||||
if entry.data.get(CONF_EMAIL) == email and entry.data.get(CONF_URL) == url:
|
||||
return_index = index
|
||||
break
|
||||
uuid = await async_get_instance_id(hass)
|
||||
result["uuid"] = hex(
|
||||
int(uuid, 16)
|
||||
# increment uuid for second accounts
|
||||
+ return_index
|
||||
# hash email/url in case HA uuid duplicated
|
||||
+ int(
|
||||
hashlib.sha256((email.lower() + url.lower()).encode()).hexdigest(),
|
||||
16, # nosec
|
||||
)
|
||||
)[-32:]
|
||||
result["index"] = return_index
|
||||
_LOGGER.debug("%s: Returning uuid %s", hide_email(email), result)
|
||||
return result
|
||||
|
||||
|
||||
def alarm_just_dismissed(
|
||||
alarm: dict[str, Any],
|
||||
previous_status: Optional[str],
|
||||
previous_version: Optional[str],
|
||||
) -> bool:
|
||||
"""Given the previous state of an alarm, determine if it has just been dismissed."""
|
||||
|
||||
if (
|
||||
previous_status not in ("SNOOZED", "ON")
|
||||
# The alarm had to be in a status that supported being dismissed
|
||||
or previous_version is None
|
||||
# The alarm was probably just created
|
||||
or not alarm
|
||||
# The alarm that was probably just deleted.
|
||||
or alarm.get("status") not in ("OFF", "ON")
|
||||
# A dismissed alarm is guaranteed to be turned off(one-off alarm) or left on(recurring alarm)
|
||||
or previous_version == alarm.get("version")
|
||||
# A dismissal always has a changed version.
|
||||
or int(alarm.get("version", "0")) > 1 + int(previous_version)
|
||||
):
|
||||
# This is an absurd thing to check, but it solves many, many edge cases.
|
||||
# Experimentally, when an alarm is dismissed, the version always increases by 1
|
||||
# When an alarm is edited either via app or voice, its version always increases by 2+
|
||||
return False
|
||||
|
||||
# It seems obvious that a check involving time should be necessary. It is not.
|
||||
# We know there was a change and that it wasn't an edit.
|
||||
# We also know the alarm's status rules out a snooze.
|
||||
# The only remaining possibility is that this alarm was just dismissed.
|
||||
return True
|
||||
|
||||
|
||||
def is_http2_enabled(hass: HomeAssistant | None, login_email: str) -> bool:
|
||||
"""Whether HTTP2 push is enabled for the current account session"""
|
||||
if hass:
|
||||
return bool(
|
||||
safe_get(
|
||||
hass.data,
|
||||
[DATA_ALEXAMEDIA, "accounts", login_email, "http2"],
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@overload
|
||||
def safe_get(
|
||||
data: Any,
|
||||
path_list: list[str | int] | None = None,
|
||||
checknone: bool = False,
|
||||
ignorecase: bool = False,
|
||||
pathsep: str = ".",
|
||||
search: Any = None,
|
||||
pretty: bool = False,
|
||||
rtype: str | None = None,
|
||||
) -> Any | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def safe_get(
|
||||
data: Any, path_list: list[str | int] | None, default: ArgType, *args, **kwargs
|
||||
) -> ArgType: ...
|
||||
|
||||
|
||||
def safe_get(
|
||||
data: Any, path_list: list[str | int] | None = None, *args, **kwargs
|
||||
) -> None | Any:
|
||||
"""Safely get nested value using path segments with optional type checking.
|
||||
|
||||
Args:
|
||||
data: Source data structure
|
||||
path_list: List of path segments (dots in segment names are auto-escaped)
|
||||
*args: Positional arguments passed to dictor (e.g., default value)
|
||||
**kwargs: Keyword arguments passed to dictor (checknone, ignorecase)
|
||||
|
||||
Returns:
|
||||
The value at the specified path, or None if:
|
||||
- The path doesn't exist and no default is provided
|
||||
or default if:
|
||||
- A default is provided and the path doesn't exist
|
||||
- A default is provided and the retrieved value's type doesn't match the default's type
|
||||
|
||||
Note:
|
||||
- Do not pass 'pathsep' in kwargs as the path is pre-built.
|
||||
- Type checking: When a default value is provided and a non-None value is retrieved,
|
||||
the result is validated against the default's type. If types don't match, default is returned.
|
||||
This prevents silent type errors from malformed data structures.
|
||||
|
||||
Examples:
|
||||
>>> safe_get({"a": {"b": "value"}}, ["a", "b"])
|
||||
'value'
|
||||
|
||||
>>> safe_get({"a": {"b": 123}}, ["a", "b"], "default")
|
||||
'default' # Type mismatch: int vs str
|
||||
|
||||
>>> safe_get({"a": {"b": "value"}}, ["a", "b"], "default")
|
||||
'value' # Type matches
|
||||
"""
|
||||
if not path_list:
|
||||
raise ValueError("path_list cannot be empty")
|
||||
|
||||
if "pathsep" in kwargs:
|
||||
kwargs.pop("pathsep") # Ignore pathsep since we build the path
|
||||
|
||||
escaped_segments = (str(seg).replace(".", "\\.") for seg in path_list)
|
||||
path = ".".join(escaped_segments)
|
||||
default = args[0] if args else (kwargs.get("default") if kwargs else None)
|
||||
result = dictor(data, path, *args, **kwargs)
|
||||
if default is not None and result is not None:
|
||||
if not isinstance(result, type(default)):
|
||||
result = default
|
||||
return result
|
||||
@@ -0,0 +1,530 @@
|
||||
"""
|
||||
Alexa Devices Lights.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from math import sqrt
|
||||
from typing import Optional
|
||||
|
||||
from alexapy import AlexaAPI, hide_serial
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_HS_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util.color import (
|
||||
color_hs_to_RGB,
|
||||
color_hsb_to_RGB,
|
||||
color_name_to_rgb,
|
||||
color_RGB_to_hs,
|
||||
)
|
||||
|
||||
from . import (
|
||||
CONF_EMAIL,
|
||||
CONF_EXCLUDE_DEVICES,
|
||||
CONF_INCLUDE_DEVICES,
|
||||
DATA_ALEXAMEDIA,
|
||||
hide_email,
|
||||
)
|
||||
from .alexa_entity import (
|
||||
parse_brightness_from_coordinator,
|
||||
parse_color_from_coordinator,
|
||||
parse_color_temp_from_coordinator,
|
||||
parse_power_from_coordinator,
|
||||
)
|
||||
from .const import CONF_EXTENDED_ENTITY_DISCOVERY
|
||||
from .helpers import add_devices, safe_get
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LOCAL_TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
"""Set up the Alexa sensor platform."""
|
||||
devices: list[LightEntity] = []
|
||||
account = None
|
||||
if config:
|
||||
account = config.get(CONF_EMAIL)
|
||||
if account is None and discovery_info:
|
||||
account = safe_get(discovery_info, ["config", CONF_EMAIL])
|
||||
if account is None:
|
||||
raise ConfigEntryNotReady
|
||||
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
|
||||
include_filter = config.get(CONF_INCLUDE_DEVICES, [])
|
||||
exclude_filter = config.get(CONF_EXCLUDE_DEVICES, [])
|
||||
coordinator = account_dict["coordinator"]
|
||||
hue_emulated_enabled = "emulated_hue" in hass.config.as_dict().get(
|
||||
"components", set()
|
||||
)
|
||||
light_entities = safe_get(account_dict, ["devices", "light"], [])
|
||||
if light_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY):
|
||||
for light_entity in light_entities:
|
||||
if not (light_entity["is_hue_v1"] and hue_emulated_enabled):
|
||||
_LOGGER.debug(
|
||||
"Creating entity %s for a light with name %s",
|
||||
hide_serial(light_entity["id"]),
|
||||
light_entity["name"],
|
||||
)
|
||||
light = AlexaLight(coordinator, account_dict["login_obj"], light_entity)
|
||||
account_dict["entities"]["light"].append(light)
|
||||
devices.append(light)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Light '%s' has not been added because it may originate from emulated_hue",
|
||||
light_entity["name"],
|
||||
)
|
||||
|
||||
return await add_devices(
|
||||
hide_email(account),
|
||||
devices,
|
||||
add_devices_callback,
|
||||
include_filter,
|
||||
exclude_filter,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the Alexa sensor platform by config_entry."""
|
||||
return await async_setup_platform(
|
||||
hass, config_entry.data, async_add_devices, discovery_info=None
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
account = entry.data[CONF_EMAIL]
|
||||
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
|
||||
_LOGGER.debug("Attempting to unload lights")
|
||||
for light in account_dict["entities"]["light"]:
|
||||
await light.async_remove()
|
||||
return True
|
||||
|
||||
|
||||
def color_modes(details) -> list:
|
||||
"""Return list of color modes."""
|
||||
if details["color"] and details["color_temperature"]:
|
||||
return [ColorMode.HS, ColorMode.COLOR_TEMP]
|
||||
if details["color"]:
|
||||
return [ColorMode.HS]
|
||||
if details["color_temperature"]:
|
||||
return [ColorMode.COLOR_TEMP]
|
||||
if details["brightness"]:
|
||||
return [ColorMode.BRIGHTNESS]
|
||||
return [ColorMode.ONOFF]
|
||||
|
||||
|
||||
class AlexaLight(CoordinatorEntity, LightEntity):
|
||||
"""A light controlled by an Echo."""
|
||||
|
||||
def __init__(self, coordinator, login, details):
|
||||
"""Initialize alexa light entity."""
|
||||
super().__init__(coordinator)
|
||||
self.alexa_entity_id = details["id"]
|
||||
self._name = details["name"]
|
||||
self._login = login
|
||||
self._attr_supported_color_modes = color_modes(details)
|
||||
self._attr_min_color_temp_kelvin = 2200
|
||||
self._attr_max_color_temp_kelvin = 6500
|
||||
|
||||
# Store the requested state from the last call to _set_state
|
||||
# This is so that no new network call is needed just to get values that are already known
|
||||
# This is useful because refreshing the full state can take a bit when many lights are in play.
|
||||
# Especially since Alexa actually polls the lights and that appears to be error-prone with some Zigbee lights.
|
||||
# That delay(1-5s in practice) causes the UI controls to jump all over the place after _set_state
|
||||
self._requested_state_at = None # When was state last set in UTC
|
||||
self._requested_power = None
|
||||
self._requested_ha_brightness = None
|
||||
self._requested_kelvin = None
|
||||
self._requested_hs = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return unique id."""
|
||||
return self.alexa_entity_id
|
||||
|
||||
@property
|
||||
def color_mode(self):
|
||||
"""Return color mode."""
|
||||
if (
|
||||
ColorMode.HS in self._attr_supported_color_modes
|
||||
and ColorMode.COLOR_TEMP in self._attr_supported_color_modes
|
||||
):
|
||||
hs_color = self.hs_color
|
||||
if hs_color is None or (hs_color[0] == 0 and hs_color[1] == 0):
|
||||
# (0,0) is white. When white, color temp is the better plan.
|
||||
return ColorMode.COLOR_TEMP
|
||||
return ColorMode.HS
|
||||
return self._attr_supported_color_modes[0]
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return whether on."""
|
||||
power = parse_power_from_coordinator(
|
||||
self.coordinator, self.alexa_entity_id, self._requested_state_at
|
||||
)
|
||||
if power is None:
|
||||
return self._requested_power if self._requested_power is not None else False
|
||||
return power == "ON"
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return brightness."""
|
||||
bright = parse_brightness_from_coordinator(
|
||||
self.coordinator, self.alexa_entity_id, self._requested_state_at
|
||||
)
|
||||
if bright is None:
|
||||
return self._requested_ha_brightness
|
||||
return alexa_brightness_to_ha(bright)
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self):
|
||||
"""Return color temperature."""
|
||||
kelvin = parse_color_temp_from_coordinator(
|
||||
self.coordinator, self.alexa_entity_id, self._requested_state_at
|
||||
)
|
||||
if kelvin is None:
|
||||
return self._requested_kelvin
|
||||
return kelvin_to_alexa(kelvin)[0]
|
||||
|
||||
@property
|
||||
def hs_color(self):
|
||||
"""Return hs color."""
|
||||
hsb = parse_color_from_coordinator(
|
||||
self.coordinator, self.alexa_entity_id, self._requested_state_at
|
||||
)
|
||||
if hsb is None:
|
||||
return self._requested_hs
|
||||
(
|
||||
adjusted_hs,
|
||||
color_name, # pylint:disable=unused-variable
|
||||
) = hsb_to_alexa_color(hsb)
|
||||
return adjusted_hs
|
||||
|
||||
@property
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return whether state is assumed."""
|
||||
last_refresh_success = (
|
||||
self.coordinator.data and self.alexa_entity_id in self.coordinator.data
|
||||
)
|
||||
return not last_refresh_success
|
||||
|
||||
async def _set_state(self, power_on, brightness=None, kelvin=None, hs_color=None):
|
||||
# This is "rounding" on kelvin to the closest value Alexa is willing to acknowledge the existence of.
|
||||
# The alternative implementation would be to use effects instead.
|
||||
# That is far more non-standard, and would lock users out of things like the Flux integration.
|
||||
# The downsides to this approach is that the UI is giving the user a slider
|
||||
# When the user picks a slider value, the UI will "jump" to the closest possible value.
|
||||
# This trade-off doesn't feel as bad in practice as it sounds.
|
||||
adjusted_kelvin, color_temperature_name = kelvin_to_alexa(kelvin)
|
||||
if color_temperature_name is None:
|
||||
# This is "rounding" on HS color to closest value Alexa supports.
|
||||
# The alexa color list is short, but covers a pretty broad spectrum.
|
||||
# Like for kelvin above, this sounds bad but works ok in practice.
|
||||
adjusted_hs, color_name = hs_to_alexa_color(hs_color)
|
||||
else:
|
||||
# If a color temperature is being set, it is not possible to also adjust the color.
|
||||
adjusted_hs = None
|
||||
color_name = None
|
||||
|
||||
response = await AlexaAPI.set_light_state(
|
||||
self._login,
|
||||
self.alexa_entity_id,
|
||||
power_on,
|
||||
brightness=ha_brightness_to_alexa(brightness),
|
||||
color_temperature_name=color_temperature_name,
|
||||
color_name=color_name,
|
||||
)
|
||||
if not isinstance(response, dict):
|
||||
return await self.coordinator.async_request_refresh()
|
||||
control_responses = response.get("controlResponses", [])
|
||||
for response in control_responses:
|
||||
if not response.get("code") == "SUCCESS":
|
||||
# If something failed any state is possible, fallback to a full refresh
|
||||
return await self.coordinator.async_request_refresh()
|
||||
self._requested_power = power_on
|
||||
self._requested_ha_brightness = (
|
||||
brightness if brightness is not None else self.brightness
|
||||
)
|
||||
self._requested_kelvin = (
|
||||
adjusted_kelvin if adjusted_kelvin is not None else self.color_temp_kelvin
|
||||
)
|
||||
if adjusted_hs is not None:
|
||||
self._requested_hs = adjusted_hs
|
||||
elif adjusted_kelvin is not None:
|
||||
# If a kelvin value was set, it is critical that color is cleared out so that color mode is set properly
|
||||
self._requested_hs = None
|
||||
else:
|
||||
self._requested_hs = self.hs_color
|
||||
self._requested_state_at = datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
) # must be set last so that previous getters work properly
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
# Confirm quickly, but debounce to avoid spamming during slider drags.
|
||||
account = self.hass.data[DATA_ALEXAMEDIA]["accounts"].get(self._login.email)
|
||||
if account:
|
||||
debouncer = account.get("confirm_refresh_debouncer")
|
||||
if debouncer:
|
||||
await debouncer.async_call()
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on."""
|
||||
brightness = None
|
||||
kelvin = None
|
||||
hs_color = None
|
||||
if (
|
||||
ColorMode.ONOFF not in self._attr_supported_color_modes
|
||||
and ATTR_BRIGHTNESS in kwargs
|
||||
):
|
||||
brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
if (
|
||||
ColorMode.COLOR_TEMP in self._attr_supported_color_modes
|
||||
and ATTR_COLOR_TEMP_KELVIN in kwargs
|
||||
):
|
||||
kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
|
||||
if ColorMode.HS in self._attr_supported_color_modes and ATTR_HS_COLOR in kwargs:
|
||||
hs_color = kwargs[ATTR_HS_COLOR]
|
||||
await self._set_state(True, brightness, kelvin, hs_color)
|
||||
|
||||
async def async_turn_off(self, **kwargs): # pylint:disable=unused-argument
|
||||
"""Turn off."""
|
||||
await self._set_state(False)
|
||||
|
||||
|
||||
def kelvin_to_alexa(kelvin: Optional[float]) -> tuple[Optional[float], Optional[str]]:
|
||||
"""Convert a given color temperature in kelvin to the closest available value that Alexa has support for."""
|
||||
if kelvin is None:
|
||||
return None, None
|
||||
if kelvin <= 2400:
|
||||
return 2200, "warm_white"
|
||||
if kelvin <= 3200:
|
||||
return 2700, "soft_white"
|
||||
if kelvin <= 4400:
|
||||
return 4000, "white"
|
||||
if kelvin <= 6000:
|
||||
return 5400, "daylight_white"
|
||||
return 6500, "cool_white"
|
||||
|
||||
|
||||
def ha_brightness_to_alexa(ha_brightness: Optional[float]) -> Optional[float]:
|
||||
"""Convert HA brightness to alexa brightness."""
|
||||
return (ha_brightness / 255 * 100) if ha_brightness is not None else None
|
||||
|
||||
|
||||
def alexa_brightness_to_ha(alexa: Optional[float]) -> Optional[float]:
|
||||
"""Convert Alexa brightness to HA brightness."""
|
||||
return (alexa / 100 * 255) if alexa is not None else None
|
||||
|
||||
|
||||
# This is a fairly complete list of all the colors that Alexa will respond to and their associated RGB value.
|
||||
ALEXA_COLORS = {
|
||||
"alice_blue": (240, 248, 255),
|
||||
"antique_white": (250, 235, 215),
|
||||
"aqua": (0, 255, 255),
|
||||
"aquamarine": (127, 255, 212),
|
||||
"azure": (240, 255, 255),
|
||||
"beige": (245, 245, 220),
|
||||
"bisque": (255, 228, 196),
|
||||
"black": (0, 0, 0),
|
||||
"blanched_almond": (255, 235, 205),
|
||||
"blue": (0, 0, 255),
|
||||
"blue_violet": (138, 43, 226),
|
||||
"brown": (165, 42, 42),
|
||||
"burlywood": (222, 184, 135),
|
||||
"cadet_blue": (95, 158, 160),
|
||||
"chartreuse": (127, 255, 0),
|
||||
"chocolate": (210, 105, 30),
|
||||
"coral": (255, 127, 80),
|
||||
"cornflower_blue": (100, 149, 237),
|
||||
"cornsilk": (255, 248, 220),
|
||||
"crimson": (220, 20, 60),
|
||||
"cyan": (0, 255, 255),
|
||||
"dark_blue": (0, 0, 139),
|
||||
"dark_cyan": (0, 139, 139),
|
||||
"dark_goldenrod": (184, 134, 11),
|
||||
"dark_green": (0, 100, 0),
|
||||
"dark_grey": (169, 169, 169),
|
||||
"dark_khaki": (189, 183, 107),
|
||||
"dark_magenta": (139, 0, 139),
|
||||
"dark_olive_green": (85, 107, 47),
|
||||
"dark_orange": (255, 140, 0),
|
||||
"dark_orchid": (153, 50, 204),
|
||||
"dark_red": (139, 0, 0),
|
||||
"dark_salmon": (233, 150, 122),
|
||||
"dark_sea_green": (143, 188, 143),
|
||||
"dark_slate_blue": (72, 61, 139),
|
||||
"dark_slate_grey": (47, 79, 79),
|
||||
"dark_turquoise": (0, 206, 209),
|
||||
"dark_violet": (148, 0, 211),
|
||||
"deep_pink": (255, 20, 147),
|
||||
"deep_sky_blue": (0, 191, 255),
|
||||
"dim_grey": (105, 105, 105),
|
||||
"dodger_blue": (30, 144, 255),
|
||||
"firebrick": (178, 34, 34),
|
||||
"floral_white": (255, 250, 240),
|
||||
"forest_green": (34, 139, 34),
|
||||
"fuchsia": (255, 0, 255),
|
||||
"gainsboro": (220, 220, 220),
|
||||
"ghost_white": (248, 248, 255),
|
||||
"gold": (255, 215, 0),
|
||||
"goldenrod": (218, 165, 32),
|
||||
"green": (0, 128, 0),
|
||||
"green_yellow": (173, 255, 47),
|
||||
"grey": (128, 128, 128),
|
||||
"honey_dew": (240, 255, 240),
|
||||
"hot_pink": (255, 105, 180),
|
||||
"indian_red": (205, 92, 92),
|
||||
"indigo": (75, 0, 130),
|
||||
"ivory": (255, 255, 240),
|
||||
"khaki": (240, 230, 140),
|
||||
"lavender": (230, 230, 250),
|
||||
"lavender_blush": (255, 240, 245),
|
||||
"lawn_green": (124, 252, 0),
|
||||
"lemon_chiffon": (255, 250, 205),
|
||||
"light_blue": (173, 216, 230),
|
||||
"light_coral": (240, 128, 128),
|
||||
"light_cyan": (224, 255, 255),
|
||||
"light_goldenrod_yellow": (250, 250, 210),
|
||||
"light_green": (144, 238, 144),
|
||||
"light_grey": (211, 211, 211),
|
||||
"light_pink": (255, 182, 193),
|
||||
"light_salmon": (255, 160, 122),
|
||||
"light_sea_green": (32, 178, 170),
|
||||
"light_sky_blue": (135, 206, 250),
|
||||
"light_slate_grey": (119, 136, 153),
|
||||
"light_steel_blue": (176, 196, 222),
|
||||
"light_yellow": (255, 255, 224),
|
||||
"lime": (0, 255, 0),
|
||||
"lime_green": (50, 205, 50),
|
||||
"linen": (250, 240, 230),
|
||||
"magenta": (255, 0, 255),
|
||||
"maroon": (128, 0, 0),
|
||||
"medium_aqua_marine": (102, 205, 170),
|
||||
"medium_blue": (0, 0, 205),
|
||||
"medium_orchid": (186, 85, 211),
|
||||
"medium_purple": (147, 112, 219),
|
||||
"medium_sea_green": (60, 179, 113),
|
||||
"medium_slate_blue": (123, 104, 238),
|
||||
"medium_spring_green": (0, 250, 154),
|
||||
"medium_turquoise": (72, 209, 204),
|
||||
"medium_violet_red": (199, 21, 133),
|
||||
"midnight_blue": (25, 25, 112),
|
||||
"mint_cream": (245, 255, 250),
|
||||
"misty_rose": (255, 228, 225),
|
||||
"moccasin": (255, 228, 181),
|
||||
"navajo_white": (255, 222, 173),
|
||||
"navy": (0, 0, 128),
|
||||
"old_lace": (253, 245, 230),
|
||||
"olive": (128, 128, 0),
|
||||
"olive_drab": (107, 142, 35),
|
||||
"orange": (255, 165, 0),
|
||||
"orange_red": (255, 69, 0),
|
||||
"orchid": (218, 112, 214),
|
||||
"pale_goldenrod": (238, 232, 170),
|
||||
"pale_green": (152, 251, 152),
|
||||
"pale_turquoise": (175, 238, 238),
|
||||
"pale_violet_red": (219, 112, 147),
|
||||
"papaya_whip": (255, 239, 213),
|
||||
"peach_puff": (255, 218, 185),
|
||||
"peru": (205, 133, 63),
|
||||
"pink": (255, 192, 203),
|
||||
"plum": (221, 160, 221),
|
||||
"powder_blue": (176, 224, 230),
|
||||
"purple": (128, 0, 128),
|
||||
"rebecca_purple": (102, 51, 153),
|
||||
"red": (255, 0, 0),
|
||||
"rosy_brown": (188, 143, 143),
|
||||
"royal_blue": (65, 105, 225),
|
||||
"saddle_brown": (139, 69, 19),
|
||||
"salmon": (250, 128, 114),
|
||||
"sandy_brown": (244, 164, 96),
|
||||
"sea_green": (46, 139, 87),
|
||||
"sea_shell": (255, 245, 238),
|
||||
"sienna": (160, 82, 45),
|
||||
"silver": (192, 192, 192),
|
||||
"sky_blue": (135, 206, 235),
|
||||
"slate_blue": (106, 90, 205),
|
||||
"slate_grey": (112, 128, 144),
|
||||
"snow": (255, 250, 250),
|
||||
"spring_green": (0, 255, 127),
|
||||
"steel_blue": (70, 130, 180),
|
||||
"tan": (210, 180, 140),
|
||||
"teal": (0, 128, 128),
|
||||
"thistle": (216, 191, 216),
|
||||
"tomato": (255, 99, 71),
|
||||
"turquoise": (64, 224, 208),
|
||||
"violet": (238, 130, 238),
|
||||
"wheat": (245, 222, 179),
|
||||
"white": (255, 255, 255),
|
||||
"white_smoke": (245, 245, 245),
|
||||
"yellow": (255, 255, 0),
|
||||
"yellow_green": (154, 205, 50),
|
||||
}
|
||||
|
||||
|
||||
def red_mean(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float:
|
||||
"""Get an approximate 'distance' between two colors using red mean.
|
||||
|
||||
Wikipedia says this method is "one of the better low-cost approximations".
|
||||
"""
|
||||
r_avg = (color2[0] + color1[0]) / 2
|
||||
r_delta = color2[0] - color1[0]
|
||||
g_delta = color2[1] - color1[1]
|
||||
b_delta = color2[2] - color1[2]
|
||||
r_term = (2 + r_avg / 256) * pow(r_delta, 2)
|
||||
g_term = 4 * pow(g_delta, 2)
|
||||
b_term = (2 + (255 - r_avg) / 256) * pow(b_delta, 2)
|
||||
return sqrt(r_term + g_term + b_term)
|
||||
|
||||
|
||||
def alexa_color_name_to_rgb(color_name: str) -> tuple[int, int, int]:
|
||||
"""Convert an alexa color name into RGB."""
|
||||
return color_name_to_rgb(color_name.replace("_", ""))
|
||||
|
||||
|
||||
def rgb_to_alexa_color(
|
||||
rgb: tuple[int, int, int],
|
||||
) -> tuple[Optional[tuple[float, float]], Optional[str]]:
|
||||
"""Convert a given RGB value into the closest Alexa color."""
|
||||
name, alexa_rgb = min(
|
||||
ALEXA_COLORS.items(),
|
||||
key=lambda alexa_color: red_mean(alexa_color[1], rgb),
|
||||
)
|
||||
red, green, blue = alexa_rgb
|
||||
return color_RGB_to_hs(red, green, blue), name
|
||||
|
||||
|
||||
def hs_to_alexa_color(
|
||||
hs_color: Optional[tuple[float, float]],
|
||||
) -> tuple[Optional[tuple[float, float]], Optional[str]]:
|
||||
"""Convert a given hue/saturation value into the closest Alexa color."""
|
||||
if hs_color is None:
|
||||
return None, None
|
||||
hue, saturation = hs_color
|
||||
return rgb_to_alexa_color(color_hs_to_RGB(hue, saturation))
|
||||
|
||||
|
||||
def hsb_to_alexa_color(
|
||||
hsb: Optional[tuple[float, float, float]],
|
||||
) -> tuple[Optional[tuple[float, float]], Optional[str]]:
|
||||
"""Convert a given hue/saturation/brightness value into the closest Alexa color."""
|
||||
if hsb is None:
|
||||
return None, None
|
||||
hue, saturation, brightness = hsb
|
||||
return rgb_to_alexa_color(color_hsb_to_RGB(hue, saturation, brightness))
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"domain": "alexa_media",
|
||||
"name": "Alexa Media Player",
|
||||
"codeowners": ["@alandtse", "@keatontaylor"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["persistent_notification", "http"],
|
||||
"documentation": "https://github.com/alandtse/alexa_media_player/wiki",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/alandtse/alexa_media_player/issues",
|
||||
"loggers": ["alexapy", "authcaptureproxy"],
|
||||
"requirements": [
|
||||
"alexapy==1.29.25",
|
||||
"packaging>=20.3",
|
||||
"wrapt>=1.14.0",
|
||||
"dictor>=0.1.12,<0.2"
|
||||
],
|
||||
"version": "5.15.6"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
"""Performance metrics and caching for Alexa Media Player.
|
||||
|
||||
Provides boot time tracking and intelligent data caching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BootMetrics:
|
||||
"""Track boot performance metrics."""
|
||||
|
||||
start_time: float = field(default_factory=time.monotonic)
|
||||
stages: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
def record_stage(self, stage_name: str) -> None:
|
||||
"""Record a boot stage completion."""
|
||||
elapsed = time.monotonic() - self.start_time
|
||||
self.stages[stage_name] = elapsed
|
||||
_LOGGER.debug(
|
||||
"[BOOT METRICS] %s completed in %.3fs",
|
||||
stage_name,
|
||||
elapsed,
|
||||
)
|
||||
|
||||
def get_summary(self) -> dict[str, Any]:
|
||||
"""Get boot metrics summary."""
|
||||
total = time.monotonic() - self.start_time
|
||||
return {
|
||||
"total_time_seconds": round(total, 3),
|
||||
"stages": {k: round(v, 3) for k, v in self.stages.items()},
|
||||
}
|
||||
|
||||
|
||||
class DataCache:
|
||||
"""Simple TTL cache for API responses.
|
||||
|
||||
Reduces redundant API calls during startup and normal operation.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: float = 30.0, max_entries: int = 128) -> None:
|
||||
"""Initialize cache with TTL.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Time-to-live for cached entries
|
||||
max_entries: Maximum number of entries before evicting oldest
|
||||
"""
|
||||
self._cache: dict[str, tuple[Any, float]] = {}
|
||||
self._ttl = ttl_seconds
|
||||
self._max_entries = max_entries
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
"""Get value from cache if not expired.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Cached value or None if expired/missing
|
||||
"""
|
||||
if key not in self._cache:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
value, timestamp = self._cache[key]
|
||||
if time.monotonic() - timestamp > self._ttl:
|
||||
# Expired
|
||||
del self._cache[key]
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
self._hits += 1
|
||||
return value
|
||||
|
||||
def cache_set(self, key: str, value: Any) -> None:
|
||||
"""Store value in cache.
|
||||
|
||||
Note: Stores a direct reference (not a copy) for performance.
|
||||
Callers should treat cached values as read-only unless the caller created
|
||||
the cached object, is solely responsible for all mutations, and intentionally
|
||||
enriches it in-place (e.g., the device-dict refresh in async_update_data).
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
value: Value to cache
|
||||
"""
|
||||
if len(self._cache) >= self._max_entries and key not in self._cache:
|
||||
oldest_key = min(self._cache, key=lambda k: self._cache[k][1])
|
||||
del self._cache[oldest_key]
|
||||
self._cache[key] = (value, time.monotonic())
|
||||
|
||||
def invalidate(self, key: str) -> None:
|
||||
"""Remove key from cache."""
|
||||
self._cache.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached entries."""
|
||||
self._cache.clear()
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
|
||||
def get_stats(self) -> dict[str, int]:
|
||||
"""Get cache statistics."""
|
||||
total = self._hits + self._misses
|
||||
hit_rate = (self._hits / total * 100) if total > 0 else 0
|
||||
return {
|
||||
"entries": len(self._cache),
|
||||
"hits": self._hits,
|
||||
"misses": self._misses,
|
||||
"hit_rate_percent": round(hit_rate, 1),
|
||||
}
|
||||
|
||||
|
||||
class AlexaMetrics:
|
||||
"""Central metrics collector for Alexa Media Player."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize metrics collector."""
|
||||
self.hass = hass
|
||||
self.boot_metrics: BootMetrics | None = None
|
||||
self.api_cache = DataCache(ttl_seconds=30.0)
|
||||
self._api_calls: dict[str, tuple[int, float]] = {} # count, total_time
|
||||
|
||||
def start_boot_tracking(self) -> None:
|
||||
"""Start tracking boot performance."""
|
||||
self.boot_metrics = BootMetrics()
|
||||
_LOGGER.debug("[BOOT METRICS] Started tracking")
|
||||
|
||||
def record_boot_stage(self, stage_name: str) -> None:
|
||||
"""Record a boot stage completion."""
|
||||
if self.boot_metrics:
|
||||
self.boot_metrics.record_stage(stage_name)
|
||||
|
||||
def record_api_call(self, endpoint: str, duration: float) -> None:
|
||||
"""Record API call metrics.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint name
|
||||
duration: Call duration in seconds
|
||||
"""
|
||||
if endpoint not in self._api_calls:
|
||||
self._api_calls[endpoint] = (0, 0.0)
|
||||
|
||||
count, total = self._api_calls[endpoint]
|
||||
self._api_calls[endpoint] = (count + 1, total + duration)
|
||||
|
||||
def get_api_stats(self) -> dict[str, Any]:
|
||||
"""Get API call statistics."""
|
||||
stats = {}
|
||||
for endpoint, (count, total) in self._api_calls.items():
|
||||
stats[endpoint] = {
|
||||
"calls": count,
|
||||
"total_time": round(total, 3),
|
||||
"avg_time": round(total / count, 3) if count > 0 else 0,
|
||||
}
|
||||
return stats
|
||||
|
||||
def get_full_report(self) -> dict[str, Any]:
|
||||
"""Get complete metrics report."""
|
||||
return {
|
||||
"boot": self.boot_metrics.get_summary() if self.boot_metrics else None,
|
||||
"cache": self.api_cache.get_stats(),
|
||||
"api_calls": self.get_api_stats(),
|
||||
}
|
||||
|
||||
|
||||
def get_metrics(hass: HomeAssistant) -> AlexaMetrics | None:
|
||||
"""Get metrics instance from hass data.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
|
||||
Returns:
|
||||
AlexaMetrics instance or None if not initialized
|
||||
"""
|
||||
if DOMAIN in hass.data and "metrics" in hass.data[DOMAIN]:
|
||||
return hass.data[DOMAIN]["metrics"]
|
||||
return None
|
||||
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Alexa Devices notification service.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from alexapy.helpers import hide_email, hide_serial
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_TARGET,
|
||||
ATTR_TITLE,
|
||||
ATTR_TITLE_DEFAULT,
|
||||
SERVICE_NOTIFY,
|
||||
BaseNotificationService,
|
||||
)
|
||||
from homeassistant.const import CONF_EMAIL
|
||||
from homeassistant.helpers.group import expand_entity_ids
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
CONF_QUEUE_DELAY,
|
||||
DATA_ALEXAMEDIA,
|
||||
DEFAULT_QUEUE_DELAY,
|
||||
DOMAIN,
|
||||
NOTIFY_URL,
|
||||
)
|
||||
from .helpers import retry_async
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@retry_async(limit=5, delay=2, catch_exceptions=True)
|
||||
async def async_get_service(hass, config, discovery_info=None):
|
||||
# pylint: disable=unused-argument
|
||||
"""Get the demo notification service."""
|
||||
result = False
|
||||
for account, account_dict in hass.data[DATA_ALEXAMEDIA]["accounts"].items():
|
||||
for key, _ in account_dict["devices"]["media_player"].items():
|
||||
if key not in account_dict["entities"]["media_player"]:
|
||||
_LOGGER.debug(
|
||||
"%s: Media player %s not loaded yet; delaying load",
|
||||
hide_email(account),
|
||||
hide_serial(key),
|
||||
)
|
||||
return False
|
||||
result = hass.data[DATA_ALEXAMEDIA]["notify_service"] = AlexaNotificationService(
|
||||
hass
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
_LOGGER.debug("Attempting to unload notify")
|
||||
target_account = entry.data[CONF_EMAIL]
|
||||
other_accounts = False
|
||||
for account, account_dict in hass.data[DATA_ALEXAMEDIA]["accounts"].items():
|
||||
if account == target_account:
|
||||
if "entities" not in account_dict:
|
||||
continue
|
||||
for device in account_dict["entities"]["media_player"].values():
|
||||
if device.entity_id:
|
||||
entity_id = device.entity_id.split(".")
|
||||
hass.services.async_remove(
|
||||
SERVICE_NOTIFY, f"{DOMAIN}_{entity_id[1]}"
|
||||
)
|
||||
else:
|
||||
other_accounts = True
|
||||
if not other_accounts:
|
||||
hass.services.async_remove(SERVICE_NOTIFY, f"{DOMAIN}")
|
||||
if hass.data[DATA_ALEXAMEDIA].get("notify_service"):
|
||||
hass.data[DATA_ALEXAMEDIA].pop("notify_service")
|
||||
return True
|
||||
|
||||
|
||||
class AlexaNotificationService(BaseNotificationService):
|
||||
"""Implement Alexa Media Player notification service."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize the service."""
|
||||
self.hass = hass
|
||||
self.last_called = True
|
||||
|
||||
def convert(self, names, type_="entities", filter_matches=False):
|
||||
"""Return a list of converted Alexa devices based on names.
|
||||
|
||||
Names may be matched either by serialNumber, accountName, or
|
||||
Homeassistant entity_id and can return any of the above plus entities
|
||||
|
||||
Parameters
|
||||
----------
|
||||
names : list(string)
|
||||
A list of names to convert
|
||||
type_ : string
|
||||
The type to return entities, entity_ids, serialnumbers, names
|
||||
filter_matches : bool
|
||||
Whether non-matching items are removed from the returned list.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list(string)
|
||||
List of home assistant entity_ids
|
||||
|
||||
"""
|
||||
devices = []
|
||||
if isinstance(names, str):
|
||||
names = [names]
|
||||
for item in names:
|
||||
matched = False
|
||||
for alexa in self.devices:
|
||||
# _LOGGER.debug(
|
||||
# "Testing item: %s against (%s, %s, %s, %s)",
|
||||
# item,
|
||||
# alexa,
|
||||
# alexa.name,
|
||||
# hide_serial(alexa.unique_id),
|
||||
# alexa.entity_id,
|
||||
# )
|
||||
if item in (
|
||||
alexa,
|
||||
alexa.name,
|
||||
alexa.unique_id,
|
||||
alexa.entity_id,
|
||||
alexa.device_serial_number,
|
||||
):
|
||||
if type_ == "entities":
|
||||
converted = alexa
|
||||
elif type_ == "serialnumbers":
|
||||
converted = alexa.device_serial_number
|
||||
elif type_ == "names":
|
||||
converted = alexa.name
|
||||
elif type_ == "entity_ids":
|
||||
converted = alexa.entity_id
|
||||
devices.append(converted)
|
||||
matched = True
|
||||
# _LOGGER.debug("Converting: %s to (%s): %s", item, type_, converted)
|
||||
if not filter_matches and not matched:
|
||||
devices.append(item)
|
||||
return devices
|
||||
|
||||
@property
|
||||
def targets(self):
|
||||
"""Return a dictionary of Alexa devices."""
|
||||
devices = {}
|
||||
for email, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items():
|
||||
if "entities" not in account_dict:
|
||||
continue
|
||||
last_called_entity = None
|
||||
for _, entity in account_dict["entities"]["media_player"].items():
|
||||
if entity is None or entity.entity_id is None:
|
||||
continue
|
||||
entity_name = (entity.entity_id).split(".")[1]
|
||||
devices[entity_name] = entity.unique_id
|
||||
if self.last_called and entity.extra_state_attributes.get(
|
||||
"last_called"
|
||||
):
|
||||
attrs = entity.extra_state_attributes
|
||||
try:
|
||||
ts = int(attrs.get("last_called_timestamp") or 0)
|
||||
except (TypeError, ValueError):
|
||||
ts = 0
|
||||
if last_called_entity is None:
|
||||
last_called_entity = entity
|
||||
else:
|
||||
best_attrs = last_called_entity.extra_state_attributes
|
||||
try:
|
||||
best_ts = int(best_attrs.get("last_called_timestamp") or 0)
|
||||
except (TypeError, ValueError):
|
||||
best_ts = 0
|
||||
if ts > best_ts:
|
||||
last_called_entity = entity
|
||||
if last_called_entity is not None:
|
||||
entity_name = (last_called_entity.entity_id).split(".")[1]
|
||||
entity_name_last_called = (
|
||||
f"last_called{'_'+ email if entity_name[-1:].isdigit() else ''}"
|
||||
)
|
||||
devices[entity_name_last_called] = last_called_entity.unique_id
|
||||
return devices
|
||||
|
||||
@property
|
||||
def devices(self):
|
||||
"""Return a list of Alexa devices."""
|
||||
devices = []
|
||||
if (
|
||||
"accounts" not in self.hass.data[DATA_ALEXAMEDIA]
|
||||
or not self.hass.data[DATA_ALEXAMEDIA]["accounts"].items()
|
||||
):
|
||||
return devices
|
||||
for _, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items():
|
||||
devices = devices + list(account_dict["entities"]["media_player"].values())
|
||||
return devices
|
||||
|
||||
async def async_send_message(self, message="", **kwargs):
|
||||
# pylint: disable=too-many-branches
|
||||
"""Send a message to an Alexa device."""
|
||||
_LOGGER.debug("Message: %s, kwargs: %s", message, kwargs)
|
||||
_LOGGER.debug("Target type: %s", type(kwargs.get(ATTR_TARGET)))
|
||||
kwargs["message"] = message
|
||||
targets = kwargs.get(ATTR_TARGET)
|
||||
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
|
||||
data = kwargs.get(ATTR_DATA, {})
|
||||
data = data if data is not None else {}
|
||||
if isinstance(targets, str):
|
||||
try:
|
||||
targets = json.loads(targets)
|
||||
except json.JSONDecodeError:
|
||||
_LOGGER.error("Target must be a valid json")
|
||||
return
|
||||
processed_targets = []
|
||||
for target in targets:
|
||||
_LOGGER.debug("Processing: %s", target)
|
||||
if not isinstance(target, str):
|
||||
processed_targets.append(target)
|
||||
_LOGGER.debug("Processed non-string target: %s", processed_targets)
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(target)
|
||||
if isinstance(parsed, list):
|
||||
processed_targets.extend(parsed)
|
||||
else:
|
||||
processed_targets.append(parsed)
|
||||
_LOGGER.debug("Processed Target by json: %s", processed_targets)
|
||||
except json.JSONDecodeError:
|
||||
if "," in target:
|
||||
processed_targets += [
|
||||
item.strip() for item in target.split(",") if item.strip()
|
||||
]
|
||||
else:
|
||||
processed_targets.append(target.strip())
|
||||
_LOGGER.debug("Processed Target by string: %s", processed_targets)
|
||||
# Expand Home Assistant group targets into member entity IDs before
|
||||
# passing to convert(). The convert() method resolves Alexa-specific
|
||||
# identifiers (entity_id, name, serial), but it does not expand HA groups.
|
||||
#
|
||||
# Supported group forms:
|
||||
# - media_player.* helper groups with an entity_id attribute
|
||||
# - old-style YAML group.* entities, via expand_entity_ids()
|
||||
#
|
||||
# Expansion happens here, while targets are still plain strings. Do not run
|
||||
# expand_entity_ids() after convert(), because convert() returns Alexa
|
||||
# objects, not entity ID strings.
|
||||
expanded_targets = []
|
||||
|
||||
for target in processed_targets:
|
||||
if not isinstance(target, str):
|
||||
expanded_targets.append(target)
|
||||
continue
|
||||
|
||||
# UI media_player group helper
|
||||
if (
|
||||
target.startswith("media_player.")
|
||||
and (state := self.hass.states.get(target)) is not None
|
||||
and "entity_id" in state.attributes
|
||||
):
|
||||
members = state.attributes["entity_id"]
|
||||
if isinstance(members, (list, tuple)):
|
||||
expanded_targets.extend(members)
|
||||
else:
|
||||
expanded_targets.append(target)
|
||||
continue
|
||||
|
||||
# Old-style YAML group.*, expand before convert()
|
||||
if target.startswith("group."):
|
||||
try:
|
||||
expanded_targets.extend(expand_entity_ids(self.hass, [target]))
|
||||
except ValueError:
|
||||
_LOGGER.debug("Invalid Home Assistant group target: %s", target)
|
||||
expanded_targets.append(target)
|
||||
continue
|
||||
|
||||
expanded_targets.append(target)
|
||||
|
||||
entities = self.convert(expanded_targets, type_="entities")
|
||||
tasks = []
|
||||
for account, account_dict in self.hass.data[DATA_ALEXAMEDIA][
|
||||
"accounts"
|
||||
].items():
|
||||
data_type = data.get("type", "tts")
|
||||
for alexa in account_dict["entities"]["media_player"].values():
|
||||
if data_type == "tts":
|
||||
targets = self.convert(
|
||||
entities, type_="entities", filter_matches=True
|
||||
)
|
||||
# _LOGGER.debug("TTS entities: %s", targets)
|
||||
if alexa in targets and alexa.available:
|
||||
_LOGGER.debug("TTS by %s : %s", alexa, message)
|
||||
tasks.append(
|
||||
alexa.async_send_tts(
|
||||
message,
|
||||
queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][
|
||||
account
|
||||
]["options"].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY),
|
||||
)
|
||||
)
|
||||
elif data_type == "announce":
|
||||
targets = self.convert(
|
||||
entities, type_="serialnumbers", filter_matches=True
|
||||
)
|
||||
# _LOGGER.debug(
|
||||
# "Announce targets: %s entities: %s",
|
||||
# list(map(hide_serial, targets)),
|
||||
# entities,
|
||||
# )
|
||||
if alexa.device_serial_number in targets and alexa.available:
|
||||
_LOGGER.debug(
|
||||
("%s: Announce by %s to targets: %s: %s"),
|
||||
hide_email(account),
|
||||
alexa,
|
||||
list(map(hide_serial, targets)),
|
||||
message,
|
||||
)
|
||||
tasks.append(
|
||||
alexa.async_send_announcement(
|
||||
message,
|
||||
targets=targets,
|
||||
title=title,
|
||||
method=(data["method"] if "method" in data else "all"),
|
||||
queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][
|
||||
account
|
||||
]["options"].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY),
|
||||
)
|
||||
)
|
||||
break
|
||||
elif data_type == "push":
|
||||
targets = self.convert(
|
||||
entities, type_="entities", filter_matches=True
|
||||
)
|
||||
if alexa in targets and alexa.available:
|
||||
_LOGGER.debug("Push by %s: %s %s", alexa, title, message)
|
||||
tasks.append(
|
||||
alexa.async_send_mobilepush(
|
||||
message,
|
||||
title=title,
|
||||
queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][
|
||||
account
|
||||
]["options"].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY),
|
||||
)
|
||||
)
|
||||
elif data_type == "dropin_notification":
|
||||
targets = self.convert(
|
||||
entities, type_="entities", filter_matches=True
|
||||
)
|
||||
if alexa in targets and alexa.available:
|
||||
_LOGGER.debug(
|
||||
"Notification dropin by %s: %s %s", alexa, title, message
|
||||
)
|
||||
tasks.append(
|
||||
alexa.async_send_dropin_notification(
|
||||
message,
|
||||
title=title,
|
||||
queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][
|
||||
account
|
||||
]["options"].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY),
|
||||
)
|
||||
)
|
||||
else:
|
||||
errormessage = (
|
||||
f"{account}: Data value `type={data_type}` is not implemented. "
|
||||
f"See {NOTIFY_URL}"
|
||||
)
|
||||
_LOGGER.debug(errormessage)
|
||||
raise vol.Invalid(errormessage)
|
||||
await asyncio.gather(*tasks)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Runtime data for Alexa Media Player integration.
|
||||
|
||||
This module implements the Platinum architecture using entry.runtime_data
|
||||
instead of the legacy hass.data[DOMAIN] pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
DEFAULT_EXTENDED_ENTITY_DISCOVERY,
|
||||
DEFAULT_PUBLIC_URL,
|
||||
DEFAULT_QUEUE_DELAY,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from alexapy import AlexaLogin, HTTP2EchoClient
|
||||
|
||||
from .coordinator import AlexaMediaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlexaRuntimeData:
|
||||
"""Runtime data for Alexa Media Player.
|
||||
|
||||
This replaces the legacy dict-based storage in hass.data[DATA_ALEXAMEDIA]["accounts"][email].
|
||||
All fields are type-safe and properly initialized.
|
||||
"""
|
||||
|
||||
# Core components (optional to support partial initialisation)
|
||||
login_obj: AlexaLogin | None = None
|
||||
config_entry: ConfigEntry | None = None
|
||||
coordinator: AlexaMediaCoordinator | None = None
|
||||
|
||||
# HTTP2 Push connection
|
||||
http2: HTTP2EchoClient | None = None
|
||||
http2_error: int = 0
|
||||
http2_lastattempt: float = 0.0
|
||||
http2_commands: dict[str, float] = field(default_factory=dict)
|
||||
http2_activity: dict[str, Any] = field(
|
||||
default_factory=lambda: {"serials": {}, "refreshed": {}}
|
||||
)
|
||||
|
||||
# Device storage
|
||||
devices: dict[str, Any] = field(
|
||||
default_factory=lambda: {
|
||||
"media_player": {},
|
||||
"switch": {},
|
||||
"guard": [],
|
||||
"light": [],
|
||||
"binary_sensor": [],
|
||||
"temperature": [],
|
||||
"smart_switch": [],
|
||||
}
|
||||
)
|
||||
entities: dict[str, Any] = field(
|
||||
default_factory=lambda: {
|
||||
"media_player": {},
|
||||
"switch": {},
|
||||
"sensor": {},
|
||||
"light": [],
|
||||
"binary_sensor": [],
|
||||
"alarm_control_panel": {},
|
||||
"smart_switch": [],
|
||||
}
|
||||
)
|
||||
excluded: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# State tracking
|
||||
new_devices: bool = True
|
||||
auth_info: dict[str, Any] | None = None
|
||||
should_get_network: bool = True
|
||||
second_account_index: int = 0
|
||||
|
||||
# Notifications
|
||||
notifications: dict[str, Any] = field(default_factory=dict)
|
||||
notifications_pending: set[str] = field(default_factory=set)
|
||||
notifications_refresh_task: asyncio.Task | None = None
|
||||
notifications_retry_count: int = 0
|
||||
last_notif_poll: float = 0.0
|
||||
|
||||
# Last called tracking
|
||||
last_called: dict[str, Any] | None = None
|
||||
last_called_customer_history_ts: int = 0
|
||||
last_called_probe_task: asyncio.Task | None = None
|
||||
last_called_probe_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
last_called_probe_last_run: float = 0.0
|
||||
last_push_activity: float = 0.0
|
||||
|
||||
# Options (mirrored from config_entry)
|
||||
options: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Listeners for cleanup
|
||||
listeners: list[Callable] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize computed fields after dataclass creation."""
|
||||
# Initialize options from config_entry if available
|
||||
if self.config_entry:
|
||||
from .const import (
|
||||
CONF_DEBUG,
|
||||
CONF_EXCLUDE_DEVICES,
|
||||
CONF_EXTENDED_ENTITY_DISCOVERY,
|
||||
CONF_INCLUDE_DEVICES,
|
||||
CONF_PUBLIC_URL,
|
||||
CONF_QUEUE_DELAY,
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
self.options = {
|
||||
CONF_INCLUDE_DEVICES: self.config_entry.data.get(
|
||||
CONF_INCLUDE_DEVICES, ""
|
||||
),
|
||||
CONF_EXCLUDE_DEVICES: self.config_entry.data.get(
|
||||
CONF_EXCLUDE_DEVICES, ""
|
||||
),
|
||||
CONF_QUEUE_DELAY: self.config_entry.data.get(
|
||||
CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY
|
||||
),
|
||||
CONF_SCAN_INTERVAL: self.config_entry.data.get(
|
||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
),
|
||||
CONF_PUBLIC_URL: self.config_entry.data.get(
|
||||
CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL
|
||||
),
|
||||
CONF_EXTENDED_ENTITY_DISCOVERY: self.config_entry.data.get(
|
||||
CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY
|
||||
),
|
||||
CONF_DEBUG: self.config_entry.data.get(CONF_DEBUG, False),
|
||||
}
|
||||
|
||||
@property
|
||||
def email(self) -> str:
|
||||
"""Return account email."""
|
||||
return self.login_obj.email if self.login_obj else ""
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Return account URL."""
|
||||
return self.login_obj.url if self.login_obj else ""
|
||||
|
||||
def get_device(self, device_type: str, serial: str) -> Any | None:
|
||||
"""Get a device by type and serial."""
|
||||
devices = self.devices.get(device_type, {})
|
||||
if isinstance(devices, dict):
|
||||
return devices.get(serial)
|
||||
if isinstance(devices, list):
|
||||
for device in devices:
|
||||
if isinstance(device, dict) and device.get("serialNumber") == serial:
|
||||
return device
|
||||
if (
|
||||
device
|
||||
and hasattr(device, "serialNumber")
|
||||
and device.serialNumber == serial
|
||||
):
|
||||
return device
|
||||
return None
|
||||
|
||||
def get_entity(self, entity_type: str, key: str) -> Any | None:
|
||||
"""Get an entity by type and key."""
|
||||
entities = self.entities.get(entity_type, {})
|
||||
if isinstance(entities, dict):
|
||||
return entities.get(key)
|
||||
if isinstance(entities, list):
|
||||
for entity in entities:
|
||||
if hasattr(entity, "unique_id") and entity.unique_id == key:
|
||||
return entity
|
||||
if hasattr(entity, "serial") and entity.serial == key:
|
||||
return entity
|
||||
return None
|
||||
|
||||
def add_listener(self, unsub: Callable) -> None:
|
||||
"""Add a listener for cleanup."""
|
||||
self.listeners.append(unsub)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
Alexa Services.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
from alexapy import AlexaAPI, AlexapyLoginError, hide_email
|
||||
from alexapy.errors import AlexapyConnectionError
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
ATTR_EMAIL,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_NUM_ENTRIES,
|
||||
DATA_ALEXAMEDIA,
|
||||
DOMAIN,
|
||||
SERVICE_ENABLE_NETWORK_DISCOVERY,
|
||||
SERVICE_FORCE_LOGOUT,
|
||||
SERVICE_GET_HISTORY_RECORDS,
|
||||
SERVICE_RESTORE_VOLUME,
|
||||
SERVICE_UPDATE_LAST_CALLED,
|
||||
)
|
||||
from .helpers import _catch_login_errors, report_relogin_required, safe_get
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FORCE_LOGOUT_SCHEMA = vol.Schema(
|
||||
{vol.Optional(ATTR_EMAIL, default=[]): vol.All(cv.ensure_list, [cv.string])}
|
||||
)
|
||||
LAST_CALL_UPDATE_SCHEMA = vol.Schema(
|
||||
{vol.Optional(ATTR_EMAIL, default=[]): vol.All(cv.ensure_list, [cv.string])}
|
||||
)
|
||||
RESTORE_VOLUME_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id})
|
||||
|
||||
GET_HISTORY_RECORDS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(ATTR_NUM_ENTRIES, default=5): cv.positive_int,
|
||||
}
|
||||
)
|
||||
|
||||
ENABLE_NETWORK_DISCOVERY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_EMAIL, default=[]): vol.All(
|
||||
cv.ensure_list,
|
||||
[cv.string],
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlexaServiceDef:
|
||||
"""Definition for an Alexa Media custom service."""
|
||||
|
||||
name: str # service name as exposed in HA: alexa_media.<name>
|
||||
schema: vol.Schema # voluptuous schema
|
||||
handler: str # method name on AlexaMediaServices
|
||||
|
||||
|
||||
SERVICE_DEFS: tuple[AlexaServiceDef, ...] = (
|
||||
AlexaServiceDef(
|
||||
name=SERVICE_FORCE_LOGOUT,
|
||||
schema=FORCE_LOGOUT_SCHEMA,
|
||||
handler="force_logout",
|
||||
),
|
||||
AlexaServiceDef(
|
||||
name=SERVICE_UPDATE_LAST_CALLED,
|
||||
schema=LAST_CALL_UPDATE_SCHEMA,
|
||||
handler="last_call_handler",
|
||||
),
|
||||
AlexaServiceDef(
|
||||
name=SERVICE_RESTORE_VOLUME,
|
||||
schema=RESTORE_VOLUME_SCHEMA,
|
||||
handler="restore_volume",
|
||||
),
|
||||
AlexaServiceDef(
|
||||
name=SERVICE_GET_HISTORY_RECORDS,
|
||||
schema=GET_HISTORY_RECORDS_SCHEMA,
|
||||
handler="get_history_records",
|
||||
),
|
||||
AlexaServiceDef(
|
||||
name=SERVICE_ENABLE_NETWORK_DISCOVERY,
|
||||
schema=ENABLE_NETWORK_DISCOVERY_SCHEMA,
|
||||
handler="enable_network_discovery",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AlexaMediaServices:
|
||||
def __init__(self, hass: HomeAssistant, functions: dict[str, Callable[..., Any]]):
|
||||
self.hass = hass
|
||||
self._functions = functions
|
||||
|
||||
async def register(self) -> None:
|
||||
"""Register Alexa Media custom services."""
|
||||
for service_def in SERVICE_DEFS:
|
||||
handler = getattr(self, service_def.handler)
|
||||
self.hass.services.async_register(
|
||||
DOMAIN,
|
||||
service_def.name,
|
||||
handler,
|
||||
schema=service_def.schema,
|
||||
)
|
||||
|
||||
async def unregister(self) -> None:
|
||||
"""Unregister Alexa Media custom services."""
|
||||
for service_def in SERVICE_DEFS:
|
||||
self.hass.services.async_remove(DOMAIN, service_def.name)
|
||||
|
||||
async def force_logout(self, call: ServiceCall) -> bool:
|
||||
"""Handle force logout service request.
|
||||
|
||||
Arguments
|
||||
call.ATTR_EMAIL {List[str] | None}: List of case-sensitive Alexa emails.
|
||||
If None, all accounts are logged out.
|
||||
|
||||
Returns
|
||||
bool -- True if at least one account was marked for relogin.
|
||||
"""
|
||||
requested_emails = call.data.get(ATTR_EMAIL)
|
||||
_LOGGER.debug("Service force_logout called for: %s", requested_emails)
|
||||
|
||||
accounts = self.hass.data[DATA_ALEXAMEDIA]["accounts"]
|
||||
success = False
|
||||
|
||||
for email, account_dict in accounts.items():
|
||||
if requested_emails and email not in requested_emails:
|
||||
continue
|
||||
|
||||
login_obj = account_dict["login_obj"]
|
||||
|
||||
# This is the effective “force logout” for this account: mark it as
|
||||
# requiring reauthentication and notify the user/UI.
|
||||
report_relogin_required(self.hass, login_obj, email)
|
||||
success = True
|
||||
_LOGGER.debug(
|
||||
"Marked Alexa Media account %s for relogin via force_logout service",
|
||||
hide_email(email),
|
||||
)
|
||||
|
||||
if requested_emails and not success:
|
||||
_LOGGER.warning(
|
||||
"force_logout called for %s but no matching Alexa Media accounts were found",
|
||||
requested_emails,
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
@_catch_login_errors
|
||||
async def last_call_handler(self, call: ServiceCall) -> None:
|
||||
"""Handle last call service request.
|
||||
|
||||
Arguments
|
||||
call.ATTR_EMAIL: {List[str: None]}: List of case-sensitive Alexa emails.
|
||||
If None, all accounts are updated.
|
||||
"""
|
||||
requested_emails = call.data.get(ATTR_EMAIL)
|
||||
update_last_called = self._functions.get("update_last_called")
|
||||
|
||||
if not callable(update_last_called):
|
||||
_LOGGER.error(
|
||||
"update_last_called function not registered; cannot update last_called"
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.debug("Service update_last_called called for: %s", requested_emails)
|
||||
|
||||
for email, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items():
|
||||
if requested_emails and email not in requested_emails:
|
||||
continue
|
||||
|
||||
login_obj = account_dict["login_obj"]
|
||||
|
||||
async def _run_update_last_called(email: str, login_obj) -> None:
|
||||
try:
|
||||
await update_last_called(login_obj)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except AlexapyLoginError:
|
||||
report_relogin_required(self.hass, login_obj, email)
|
||||
except AlexapyConnectionError:
|
||||
_LOGGER.error(
|
||||
"Unable to connect to Alexa for %s;"
|
||||
" check your network connection and try again",
|
||||
hide_email(email),
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
_LOGGER.exception(
|
||||
"Unexpected error updating last_called for %s",
|
||||
hide_email(email),
|
||||
)
|
||||
finally:
|
||||
# Clean up task reference when done
|
||||
if email in self.hass.data[DATA_ALEXAMEDIA]["accounts"]:
|
||||
self.hass.data[DATA_ALEXAMEDIA]["accounts"][email].pop(
|
||||
"service_update_last_called_task", None
|
||||
)
|
||||
|
||||
# Cancel any existing task for this account before creating a new one
|
||||
existing_task = account_dict.get("service_update_last_called_task")
|
||||
if existing_task and not existing_task.done():
|
||||
existing_task.cancel()
|
||||
|
||||
# Store task handle for proper cleanup on unload
|
||||
task = self.hass.async_create_task(
|
||||
_run_update_last_called(email, login_obj),
|
||||
name=f"alexa_media.update_last_called.{hide_email(email)}",
|
||||
)
|
||||
account_dict["service_update_last_called_task"] = task
|
||||
|
||||
async def restore_volume(self, call: ServiceCall) -> bool:
|
||||
"""Handle restore volume service request.
|
||||
|
||||
Arguments:
|
||||
call.ATTR_ENTITY_ID {str: None} -- Alexa Media Player entity.
|
||||
|
||||
"""
|
||||
entity_id = call.data.get(ATTR_ENTITY_ID)
|
||||
_LOGGER.debug("Service restore_volume called for: %s", entity_id)
|
||||
|
||||
# Retrieve the entity registry and entity entry
|
||||
entity_registry = er.async_get(self.hass)
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
|
||||
if not entity_entry:
|
||||
_LOGGER.error("Entity %s not found in registry", entity_id)
|
||||
return False
|
||||
|
||||
# Retrieve the state and attributes
|
||||
state = self.hass.states.get(entity_id)
|
||||
if not state:
|
||||
_LOGGER.warning("Entity %s has no state; cannot restore volume", entity_id)
|
||||
return False
|
||||
|
||||
previous_volume = state.attributes.get("previous_volume")
|
||||
current_volume = state.attributes.get("volume_level")
|
||||
|
||||
if previous_volume is None:
|
||||
_LOGGER.warning(
|
||||
"Previous volume not found for %s; attempting to use current volume level: %s",
|
||||
entity_id,
|
||||
current_volume,
|
||||
)
|
||||
previous_volume = current_volume
|
||||
|
||||
if previous_volume is None:
|
||||
_LOGGER.warning(
|
||||
"No valid volume levels found for entity %s; cannot restore volume",
|
||||
entity_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Call the volume_set service with the retrieved volume
|
||||
await self.hass.services.async_call(
|
||||
domain="media_player",
|
||||
service="volume_set",
|
||||
service_data={
|
||||
"volume_level": previous_volume,
|
||||
},
|
||||
target={"entity_id": entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Volume restored to %s for entity %s", previous_volume, entity_id)
|
||||
return True
|
||||
|
||||
async def get_history_records(self, call: ServiceCall) -> bool:
|
||||
"""Handle request to get history records and store them on the entity."""
|
||||
entity_id = call.data.get(ATTR_ENTITY_ID)
|
||||
number_of_entries = call.data.get(ATTR_NUM_ENTRIES)
|
||||
|
||||
# Validate number_of_entries
|
||||
try:
|
||||
number_of_entries_int = int(number_of_entries)
|
||||
except (TypeError, ValueError):
|
||||
_LOGGER.exception(
|
||||
"Service get_history_records for %s has invalid entries value: %s",
|
||||
entity_id,
|
||||
number_of_entries,
|
||||
)
|
||||
return False
|
||||
|
||||
if number_of_entries_int <= 0:
|
||||
_LOGGER.error(
|
||||
"Service get_history_records for %s with %s entries is invalid; must be > 0",
|
||||
entity_id,
|
||||
number_of_entries_int,
|
||||
)
|
||||
return False
|
||||
|
||||
_LOGGER.debug(
|
||||
"Service get_history_records for: %s with %s entries",
|
||||
entity_id,
|
||||
number_of_entries_int,
|
||||
)
|
||||
|
||||
# Validate the target entity
|
||||
entity_registry = er.async_get(self.hass)
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
if not entity_entry or entity_entry.platform != DOMAIN:
|
||||
_LOGGER.error("Entity %s not found or not part of %s", entity_id, DOMAIN)
|
||||
return False
|
||||
target_serial_number = entity_entry.unique_id
|
||||
|
||||
history_data_total: list[dict[str, Any]] = []
|
||||
|
||||
async def _collect_history_for_account(login_obj) -> None:
|
||||
"""Collect history entries for a single account matching the target device."""
|
||||
# Get the history records. Input: time_from, time_to (both None here).
|
||||
history_data = await AlexaAPI.get_customer_history_records(
|
||||
login_obj, None, None
|
||||
)
|
||||
if not history_data:
|
||||
return
|
||||
|
||||
for item in history_data:
|
||||
summary = safe_get(item, ["description", "summary"], "")
|
||||
device_serial_number = item.get("deviceSerialNumber")
|
||||
timestamp = item.get("creationTimestamp")
|
||||
|
||||
if (
|
||||
not summary
|
||||
or summary == ","
|
||||
or device_serial_number != target_serial_number
|
||||
or timestamp is None
|
||||
):
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"timestamp": timestamp,
|
||||
"summary": summary,
|
||||
"response": item.get("alexaResponse", ""),
|
||||
}
|
||||
history_data_total.append(entry)
|
||||
|
||||
# Iterate accounts and collect history
|
||||
for email, account_dict in self.hass.data[DATA_ALEXAMEDIA]["accounts"].items():
|
||||
login_obj = account_dict["login_obj"]
|
||||
try:
|
||||
await _collect_history_for_account(login_obj)
|
||||
except AlexapyConnectionError:
|
||||
_LOGGER.exception(
|
||||
"Error retrieving history for %s",
|
||||
hide_email(email),
|
||||
)
|
||||
except AlexapyLoginError:
|
||||
_LOGGER.exception(
|
||||
"Login error retrieving history for %s",
|
||||
hide_email(email),
|
||||
)
|
||||
report_relogin_required(self.hass, login_obj, email)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Let HA cancellation propagate
|
||||
raise
|
||||
except Exception:
|
||||
# Fallback for truly unexpected errors
|
||||
_LOGGER.exception(
|
||||
"Unexpected error retrieving history for %s",
|
||||
hide_email(email),
|
||||
)
|
||||
|
||||
# Sort and limit entries
|
||||
history_data_total.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
history_data_total = history_data_total[:number_of_entries_int]
|
||||
|
||||
# Update the entity's attributes
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is not None:
|
||||
new_attributes = dict(state.attributes)
|
||||
new_attributes["history_records"] = history_data_total
|
||||
self.hass.states.async_set(entity_id, state.state, new_attributes)
|
||||
return True
|
||||
|
||||
_LOGGER.error("Entity %s state not found", entity_id)
|
||||
return False
|
||||
|
||||
async def enable_network_discovery(self, call: ServiceCall) -> None:
|
||||
"""Re-enable network discovery for one or more Alexa accounts."""
|
||||
data = call.data or {}
|
||||
target_emails: list[str] = data.get(ATTR_EMAIL, [])
|
||||
|
||||
accounts = self.hass.data[DATA_ALEXAMEDIA]["accounts"]
|
||||
any_matched = False
|
||||
|
||||
for email, account_dict in accounts.items():
|
||||
if target_emails and email not in target_emails:
|
||||
continue
|
||||
|
||||
any_matched = True
|
||||
|
||||
if "should_get_network" not in account_dict:
|
||||
_LOGGER.debug(
|
||||
"Account %s has no 'should_get_network' flag; skipping",
|
||||
hide_email(email),
|
||||
)
|
||||
continue
|
||||
|
||||
account_dict["should_get_network"] = True
|
||||
_LOGGER.debug(
|
||||
"Re-enabled network discovery for Alexa Media account %s",
|
||||
hide_email(email),
|
||||
)
|
||||
|
||||
if target_emails and not any_matched:
|
||||
_LOGGER.warning(
|
||||
"enable_network_discovery called for %s but no matching Alexa Media accounts were found",
|
||||
target_emails,
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
force_logout:
|
||||
# Description of the service
|
||||
description: Force logout of Alexa Login account and deletion of .pickle. Intended for debugging use.
|
||||
# Different fields that your service accepts
|
||||
fields:
|
||||
# Key of the field
|
||||
email:
|
||||
# Description of the field
|
||||
description: List of Alexa accounts to log out. If empty, will log out from all known accounts.
|
||||
# Example value that can be passed for this field
|
||||
example: "my_email@alexa.com"
|
||||
|
||||
restore_volume:
|
||||
description: Restores an Alexa Media Player volume level to the previous volume level.
|
||||
fields:
|
||||
entity_id:
|
||||
name: Entity
|
||||
description: Alexa Media Player device to restore volume on.
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: media_player
|
||||
integration: alexa_media
|
||||
|
||||
get_history_records:
|
||||
description: Returns the last entries of all the customer history.
|
||||
fields:
|
||||
entity_id:
|
||||
name: Entity
|
||||
description: Alexa Media Player device to get history from.
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: media_player
|
||||
integration: alexa_media
|
||||
entries:
|
||||
name: Entries
|
||||
description: Number of records to return.
|
||||
required: false
|
||||
default: 5
|
||||
example: 5
|
||||
|
||||
update_last_called:
|
||||
# Description of the service
|
||||
description: Forces update of last_called echo device for each Alexa account.
|
||||
# Different fields that your service accepts
|
||||
fields:
|
||||
# Key of the field
|
||||
email:
|
||||
# Description of the field
|
||||
description: List of Alexa accounts to update. If empty, will update all known accounts.
|
||||
# Example value that can be passed for this field
|
||||
example: "my_email@alexa.com"
|
||||
|
||||
enable_network_discovery:
|
||||
name: Enable network discovery
|
||||
description: >
|
||||
Re-enable Alexa network discovery so the next polling cycle will
|
||||
rediscover Alexa devices for the selected accounts.
|
||||
fields:
|
||||
email:
|
||||
name: Account email(s)
|
||||
description: >
|
||||
Optional Alexa account email or list of emails. If omitted,
|
||||
all Alexa Media accounts will be refreshed.
|
||||
required: false
|
||||
example: [email protected]
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "The Forgot Password page was detected. This normally is the result of too many failed logins. Amazon may require action before a relogin can be attempted.",
|
||||
"login_failed": "Alexa Media Player failed to login.",
|
||||
"reauth_successful": "Alexa Media Player successfully reauthenticated. Please ignore the \"Aborted\" message from HA."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Error connecting; check network and retry",
|
||||
"identifier_exists": "Email for Alexa URL already registered",
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"invalid_auth": "Login was not successful. Please double-check your email, password, and Authenticator key.",
|
||||
"oauth_error": "Could not complete OAuth login. Please try again.",
|
||||
"invalid_url": "URL is invalid: {message}",
|
||||
"2fa_key_invalid": "{otp_secret} is invalid",
|
||||
"unable_to_connect_hass_url": "Unable to connect to Home Assistant Local URL. Please check the URL under Settings > System > Network > Home Assistant URL > Local network",
|
||||
"unknown_error": "Unknown error: {message}"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"email": "Email Address",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"hass_url": "Local network URL to access Home Assistant",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"password": "Password",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"securitycode": "One-time password (OTP)",
|
||||
"should_get_network": "Discover Alexa network",
|
||||
"url": "Amazon region domain (e.g., amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
},
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignore and Continue - I understand that no support for login issues are provided for bypassing this warning."
|
||||
},
|
||||
"description": "The HA server cannot connect to the URL provided: {hass_url}.\n> {error}\n\nTo fix this, please confirm your browser can reach {hass_url}. This field is from Settings > System > Network > Home Assistant URL.\n\nIf you are **certain** your browser can reach this URL, you can bypass this warning.",
|
||||
"title": "Alexa Media Player - Unable to Connect to HA URL"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Yes, OTP code was verified"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \nHave you verified the OTP code in Amazon 2SV? \n >OTP Code: {message}",
|
||||
"title": "Alexa Media Player - OTP Confirmation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Alexa Media Player - Reconfiguration",
|
||||
"description": "* Required entry",
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"should_get_network": "Discover Alexa network"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"force_logout": {
|
||||
"name": "Force Logout",
|
||||
"description": "Force account to logout. Used mainly for debugging.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"name": "Email address",
|
||||
"description": "Accounts to clear. Empty will clear all."
|
||||
}
|
||||
}
|
||||
},
|
||||
"restore_volume": {
|
||||
"name": "Restore Previous Volume",
|
||||
"description": "Restore previous volume level on Alexa media player device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Select media player:",
|
||||
"description": "Entity to restore the previous volume level on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"get_history_records": {
|
||||
"name": "Get History Records",
|
||||
"description": "Parses the history records for the specified device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Select media player:",
|
||||
"description": "Entity to get the history for"
|
||||
},
|
||||
"entries": {
|
||||
"name": "Number of entries",
|
||||
"description": "Number of entries to get"
|
||||
}
|
||||
}
|
||||
},
|
||||
"update_last_called": {
|
||||
"name": "Update Last Called Sensor",
|
||||
"description": "Forces update of last_called echo device for each Alexa account.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"name": "Email address",
|
||||
"description": "List of Alexa accounts to update. If empty, will update all known accounts."
|
||||
}
|
||||
}
|
||||
},
|
||||
"enable_network_discovery": {
|
||||
"name": "Enable Network Discovery",
|
||||
"description": "Re-enables Alexa network discovery so the next polling cycle will rediscover Alexa devices for the selected accounts.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"name": "Email address",
|
||||
"description": "Optional Alexa account email or list of emails. If empty, all known accounts will be refreshed."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repeat"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Carbon monoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Indoor air quality"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Particulate matter"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Next timer"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Next reminder"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"title": "YAML configuration is deprecated",
|
||||
"description": "YAML configuration of Alexa Media Player is deprecated.\nPlease remove `alexa_media` from your configuration, restart Home Assistant and use the UI to configure it instead.\nSettings > Devices & services > Integrations > ADD INTEGRATION"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
"""
|
||||
Alexa Devices Switches.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from alexapy import AlexaAPI
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import (
|
||||
CONF_EMAIL,
|
||||
CONF_EXCLUDE_DEVICES,
|
||||
CONF_INCLUDE_DEVICES,
|
||||
DATA_ALEXAMEDIA,
|
||||
DOMAIN as ALEXA_DOMAIN,
|
||||
hide_email,
|
||||
hide_serial,
|
||||
)
|
||||
from .alexa_entity import parse_power_from_coordinator
|
||||
from .alexa_media import AlexaMedia
|
||||
from .const import CONF_EXTENDED_ENTITY_DISCOVERY
|
||||
from .helpers import _catch_login_errors, add_devices, safe_get
|
||||
|
||||
try:
|
||||
from homeassistant.components.switch import SwitchEntity as SwitchDevice
|
||||
except ImportError:
|
||||
from homeassistant.components.switch import SwitchDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
"""Set up the Alexa switch platform."""
|
||||
devices: list[DNDSwitch] = []
|
||||
SWITCH_TYPES = [ # pylint: disable=invalid-name
|
||||
("dnd", DNDSwitch),
|
||||
("shuffle", ShuffleSwitch),
|
||||
("repeat", RepeatSwitch),
|
||||
]
|
||||
account = None
|
||||
if config:
|
||||
account = config.get(CONF_EMAIL)
|
||||
if account is None and discovery_info:
|
||||
account = safe_get(discovery_info, ["config", CONF_EMAIL])
|
||||
if account is None:
|
||||
raise ConfigEntryNotReady
|
||||
include_filter = config.get(CONF_INCLUDE_DEVICES, [])
|
||||
exclude_filter = config.get(CONF_EXCLUDE_DEVICES, [])
|
||||
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
|
||||
_LOGGER.debug("%s: Loading switches", hide_email(account))
|
||||
if "switch" not in account_dict["entities"]:
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["switch"] = {}
|
||||
for key, _ in account_dict["devices"]["media_player"].items():
|
||||
if key not in account_dict["entities"]["media_player"]:
|
||||
_LOGGER.debug(
|
||||
"%s: Media player %s not loaded yet; delaying load",
|
||||
hide_email(account),
|
||||
hide_serial(key),
|
||||
)
|
||||
raise ConfigEntryNotReady
|
||||
if key not in (
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["switch"]
|
||||
):
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["switch"][
|
||||
key
|
||||
] = {}
|
||||
for switch_key, class_ in SWITCH_TYPES:
|
||||
if (
|
||||
switch_key == "dnd"
|
||||
and not safe_get(account_dict, ["devices", "switch", key, "dnd"])
|
||||
) or (
|
||||
switch_key in ["shuffle", "repeat"]
|
||||
and "MUSIC_SKILL"
|
||||
not in account_dict["devices"]["media_player"]
|
||||
.get(key, {})
|
||||
.get("capabilities", {})
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"%s: Skipping %s for %s",
|
||||
hide_email(account),
|
||||
switch_key,
|
||||
hide_serial(key),
|
||||
)
|
||||
continue
|
||||
alexa_client = class_(
|
||||
account_dict["entities"]["media_player"][key]
|
||||
) # type: AlexaMediaSwitch
|
||||
_LOGGER.debug(
|
||||
"%s: Found %s %s switch with status: %s",
|
||||
hide_email(account),
|
||||
hide_serial(key),
|
||||
switch_key,
|
||||
alexa_client.is_on,
|
||||
)
|
||||
devices.append(alexa_client)
|
||||
(
|
||||
hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][
|
||||
"switch"
|
||||
][key][switch_key]
|
||||
) = alexa_client
|
||||
else:
|
||||
for alexa_client in hass.data[DATA_ALEXAMEDIA]["accounts"][account][
|
||||
"entities"
|
||||
]["switch"][key].values():
|
||||
_LOGGER.debug(
|
||||
"%s: Skipping already added device: %s",
|
||||
hide_email(account),
|
||||
alexa_client,
|
||||
)
|
||||
# Add Amazon Smart Plug devices
|
||||
switch_entities = safe_get(account_dict, ["devices", "smart_switch"], [])
|
||||
hue_emulated_enabled = "emulated_hue" in hass.config.as_dict().get(
|
||||
"components", set()
|
||||
)
|
||||
if switch_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY):
|
||||
for switch_entity in switch_entities:
|
||||
if not (switch_entity["is_hue_v1"] and hue_emulated_enabled):
|
||||
_LOGGER.debug(
|
||||
"Creating entity %s for a switch with name %s",
|
||||
hide_serial(switch_entity["id"]),
|
||||
switch_entity["name"],
|
||||
)
|
||||
coordinator = account_dict["coordinator"]
|
||||
switch = SmartSwitch(
|
||||
coordinator, account_dict["login_obj"], switch_entity
|
||||
)
|
||||
account_dict["entities"]["smart_switch"].append(switch)
|
||||
devices.append(switch)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Switch '%s' has not been added because it may originate from emulated_hue",
|
||||
switch_entity["name"],
|
||||
)
|
||||
return await add_devices(
|
||||
hide_email(account),
|
||||
devices,
|
||||
add_devices_callback,
|
||||
include_filter,
|
||||
exclude_filter,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Set up the Alexa switch platform by config_entry."""
|
||||
return await async_setup_platform(
|
||||
hass, config_entry.data, async_add_devices, discovery_info=None
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
account = entry.data[CONF_EMAIL]
|
||||
_LOGGER.debug("Attempting to unload switch")
|
||||
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
|
||||
for key, switches in account_dict["entities"]["switch"].items():
|
||||
for device in switches[key].values():
|
||||
_LOGGER.debug("Removing %s", device)
|
||||
await device.async_remove()
|
||||
return True
|
||||
|
||||
|
||||
class AlexaMediaSwitch(SwitchDevice, AlexaMedia):
|
||||
"""Representation of a Alexa Media switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client,
|
||||
switch_property: str,
|
||||
switch_function: str,
|
||||
unique_id_suffix: str = "switch",
|
||||
):
|
||||
"""Initialize the Alexa Switch device."""
|
||||
# Class info
|
||||
self._client = client
|
||||
self._unique_id_suffix = unique_id_suffix
|
||||
self._switch_property = switch_property
|
||||
self._switch_function = switch_function
|
||||
super().__init__(client, client._login)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Store register state change callback."""
|
||||
try:
|
||||
if not self.enabled:
|
||||
return
|
||||
except AttributeError:
|
||||
pass
|
||||
# Register event handler on bus
|
||||
self._listener = async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{ALEXA_DOMAIN}_{hide_email(self.email)}"[0:32],
|
||||
self._handle_event,
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Prepare to remove entity."""
|
||||
# Register event handler on bus
|
||||
self._listener()
|
||||
|
||||
def _handle_event(self, event):
|
||||
"""Handle events.
|
||||
|
||||
This will update PUSH_MEDIA_QUEUE_CHANGE events to see if the switch
|
||||
should be updated.
|
||||
"""
|
||||
try:
|
||||
if not self.enabled:
|
||||
return
|
||||
except AttributeError:
|
||||
pass
|
||||
if "queue_state" in event:
|
||||
queue_state = event["queue_state"]
|
||||
if queue_state["dopplerId"]["deviceSerialNumber"] == self._client.unique_id:
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@_catch_login_errors
|
||||
async def _set_switch(self, state, **kwargs):
|
||||
# pylint: disable=unused-argument
|
||||
try:
|
||||
if not self.enabled:
|
||||
return
|
||||
except AttributeError:
|
||||
pass
|
||||
success = await getattr(self.alexa_api, self._switch_function)(state)
|
||||
# if function returns success, make immediate state change
|
||||
if success:
|
||||
setattr(self._client, self._switch_property, state)
|
||||
_LOGGER.debug(
|
||||
"Setting %s to %s",
|
||||
self.name,
|
||||
getattr(self._client, self._switch_property),
|
||||
)
|
||||
self.schedule_update_ha_state()
|
||||
elif self.should_poll:
|
||||
# if we need to poll, refresh media_client
|
||||
_LOGGER.debug(
|
||||
"Requesting update of %s due to %s switch to %s",
|
||||
self._client,
|
||||
self._unique_id_suffix,
|
||||
state,
|
||||
)
|
||||
await self._client.async_update()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if on."""
|
||||
return self.available and getattr(self._client, self._switch_property)
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on switch."""
|
||||
await self._set_switch(True, **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn off switch."""
|
||||
await self._set_switch(False, **kwargs)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return the availability of the switch."""
|
||||
return (
|
||||
self._client.available
|
||||
and getattr(self._client, self._switch_property) is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return whether the state is an assumed_state."""
|
||||
return self._client.assumed_state
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique ID."""
|
||||
return self._client.unique_id + "_" + self._unique_id_suffix
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device_class of the switch."""
|
||||
return "switch"
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
"""Return whether the switch should be hidden from the UI."""
|
||||
return not self.available
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return True
|
||||
|
||||
@_catch_login_errors
|
||||
async def async_update(self):
|
||||
"""Update state."""
|
||||
try:
|
||||
if not self.enabled:
|
||||
return
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
self.schedule_update_ha_state()
|
||||
except NoEntitySpecifiedError:
|
||||
pass # we ignore this due to a harmless startup race condition
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device_info for device registry."""
|
||||
return {
|
||||
"identifiers": {(ALEXA_DOMAIN, self._client.unique_id)},
|
||||
"via_device": (ALEXA_DOMAIN, self._client.unique_id),
|
||||
}
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the switch."""
|
||||
return self._icon()
|
||||
|
||||
def _icon(self, on=None, off=None): # pylint: disable=invalid-name
|
||||
return on if self.is_on else off
|
||||
|
||||
|
||||
class DNDSwitch(AlexaMediaSwitch):
|
||||
"""Representation of a Alexa Media Do Not Disturb switch."""
|
||||
|
||||
_attr_translation_key = "do_not_disturb"
|
||||
|
||||
def __init__(self, client):
|
||||
"""Initialize the Alexa Switch."""
|
||||
# Class info
|
||||
super().__init__(
|
||||
client,
|
||||
"dnd_state",
|
||||
"set_dnd_state",
|
||||
"do not disturb", # Keep original suffix for backward compatibility
|
||||
)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the switch."""
|
||||
return super()._icon("mdi:minus-circle", "mdi:minus-circle-off")
|
||||
|
||||
@property
|
||||
def entity_category(self):
|
||||
"""Return the entity category of the switch."""
|
||||
return EntityCategory.CONFIG
|
||||
|
||||
def _handle_event(self, event):
|
||||
"""Handle events."""
|
||||
try:
|
||||
if not self.enabled:
|
||||
return
|
||||
except AttributeError:
|
||||
pass
|
||||
if "dnd_update" in event:
|
||||
result = list(
|
||||
filter(
|
||||
lambda x: x["deviceSerialNumber"]
|
||||
== self._client.device_serial_number,
|
||||
event["dnd_update"],
|
||||
)
|
||||
)
|
||||
if result:
|
||||
state = result[0]["enabled"] is True
|
||||
if state != self.is_on:
|
||||
_LOGGER.debug("Detected %s changed to %s", self, state)
|
||||
setattr(self._client, self._switch_property, state)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
|
||||
class ShuffleSwitch(AlexaMediaSwitch):
|
||||
"""Representation of a Alexa Media Shuffle switch."""
|
||||
|
||||
_attr_translation_key = "shuffle"
|
||||
|
||||
def __init__(self, client):
|
||||
"""Initialize the Alexa Switch."""
|
||||
# Class info
|
||||
super().__init__(client, "shuffle", "shuffle", "shuffle")
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the switch."""
|
||||
return super()._icon("mdi:shuffle", "mdi:shuffle-disabled")
|
||||
|
||||
@property
|
||||
def entity_category(self):
|
||||
"""Return the entity category of the switch."""
|
||||
return EntityCategory.CONFIG
|
||||
|
||||
|
||||
class RepeatSwitch(AlexaMediaSwitch):
|
||||
"""Representation of a Alexa Media Repeat switch."""
|
||||
|
||||
_attr_translation_key = "repeat"
|
||||
|
||||
def __init__(self, client):
|
||||
"""Initialize the Alexa Switch."""
|
||||
# Class info
|
||||
super().__init__(client, "repeat_state", "repeat", "repeat")
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the switch."""
|
||||
return super()._icon("mdi:repeat", "mdi:repeat-off")
|
||||
|
||||
@property
|
||||
def entity_category(self):
|
||||
"""Return the entity category of the switch."""
|
||||
return EntityCategory.CONFIG
|
||||
|
||||
|
||||
class SmartSwitch(CoordinatorEntity, SwitchDevice):
|
||||
def __init__(self, coordinator, login, details):
|
||||
"""Initialize alexa light entity."""
|
||||
super().__init__(coordinator)
|
||||
self.alexa_entity_id = details["id"]
|
||||
self._name = details["name"]
|
||||
self._login = login
|
||||
|
||||
# Store the requested state from the last call to _set_state
|
||||
# This is so that no new network call is needed just to get values that are already known
|
||||
# This is useful because refreshing the full state can take a bit when many switches are in play.
|
||||
# Especially since Alexa actually polls the switches and that appears to be error-prone with some Zigbee lights.
|
||||
# That delay(1-5s in practice) causes the UI controls to jump all over the place after _set_state
|
||||
self._requested_state_at = None # When was state last set in UTC
|
||||
self._requested_power = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return unique id."""
|
||||
return self.alexa_entity_id
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return whether on."""
|
||||
power = parse_power_from_coordinator(
|
||||
self.coordinator, self.alexa_entity_id, self._requested_state_at
|
||||
)
|
||||
if power is None:
|
||||
return self._requested_power if self._requested_power is not None else False
|
||||
return power == "ON"
|
||||
|
||||
@property
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return whether state is assumed."""
|
||||
last_refresh_success = (
|
||||
self.coordinator.data and self.alexa_entity_id in self.coordinator.data
|
||||
)
|
||||
return not last_refresh_success
|
||||
|
||||
async def _set_state(self, power_on: bool) -> None:
|
||||
response = await AlexaAPI.set_light_state(
|
||||
self._login,
|
||||
self.alexa_entity_id,
|
||||
power_on,
|
||||
)
|
||||
|
||||
if not isinstance(response, dict):
|
||||
# If something failed any state is possible, fallback to a full refresh
|
||||
await self.coordinator.async_request_refresh()
|
||||
return
|
||||
|
||||
control_responses = response.get("controlResponses", [])
|
||||
for ctrl_resp in control_responses:
|
||||
if ctrl_resp.get("code") != "SUCCESS":
|
||||
# If something failed any state is possible, fallback to a full refresh
|
||||
await self.coordinator.async_request_refresh()
|
||||
return
|
||||
|
||||
self._requested_power = power_on
|
||||
self._requested_state_at = datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
) # must be set last so that previous getters work properly
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
# Confirm quickly, but debounce to avoid spamming across multiple entities.
|
||||
account = self.hass.data[DATA_ALEXAMEDIA]["accounts"].get(self._login.email)
|
||||
if account:
|
||||
debouncer = account.get("confirm_refresh_debouncer")
|
||||
if debouncer:
|
||||
await debouncer.async_call()
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on."""
|
||||
await self._set_state(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs): # pylint:disable=unused-argument
|
||||
"""Turn off."""
|
||||
await self._set_state(False)
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "تم الكشف عن صفحة نسيت كلمة المرور. عادةً ما يكون هذا نتيجة لمحاولات تسجيل دخول فاشلة كثيرة. قد تتطلب أمازون اتخاذ إجراء قبل محاولة تسجيل الدخول مرة أخرى.",
|
||||
"login_failed": "فشل تسجيل الدخول إلى Alexa Media Player.",
|
||||
"reauth_successful": "تمت إعادة التحقق من Alexa Media Player بنجاح. يرجى تجاهل رسالة \"تم الإلغاء\" من Home Assistant."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} غير صالح",
|
||||
"connection_error": "خطأ في الاتصال؛ تحقق من الشبكة وأعد المحاولة",
|
||||
"identifier_exists": "البريد الإلكتروني لرابط Alexa مسجل مسبقًا",
|
||||
"invalid_auth": "لم تنجح عملية تسجيل الدخول. يرجى التحقق من بريدك الإلكتروني وكلمة المرور ومفتاح المصادقة.",
|
||||
"invalid_credentials": "بيانات اعتماد غير صالحة",
|
||||
"invalid_url": "رابط غير صالح: {message}",
|
||||
"oauth_error": "تعذر إكمال تسجيل الدخول عبر OAuth. يرجى المحاولة مرة أخرى.",
|
||||
"unable_to_connect_hass_url": "غير قادر على الاتصال بالرابط المحلي لـ Home Assistant. يرجى التحقق من العنوان ضمن:\nالإعدادات > النظام > الشبكة > رابط Home Assistant > الشبكة المحلية.",
|
||||
"unknown_error": "خطأ غير معروف: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "تجاهل ومتابعة - أتفهم أنه لا يوجد دعم لمشاكل تسجيل الدخول لتجاوز هذا التحذير."
|
||||
},
|
||||
"description": "لا يمكن لخادم Home Assistant الاتصال بالرابط المقدم: {hass_url}. \n > {error} \n \n لإصلاح هذه المشكلة، يرجى التأكد من أن متصفحك يمكنه الوصول إلى {hass_url}. هذا الحقل موجود في الإعدادات > النظام > الشبكة > رابط Home Assistant. \n \n إذا كنت **متأكدًا** من أن متصفحك يمكنه الوصول إلى هذا الرابط، فيمكنك تجاوز هذا التحذير.",
|
||||
"title": "Alexa Media Player - غير قادر على الاتصال برابط Home Assistant"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "نعم، تم التحقق من رمز OTP"
|
||||
},
|
||||
"description": "** {email} - alexa. {url} ** \n هل قمت بالتحقق من رمز OTP في Amazon 2SV؟ \n >رمز OTP: {message}",
|
||||
"title": "Alexa Media Player - تأكيد OTP"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "تصحيح الأخطاء المتقدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
"exclude_devices": "أو استبعاد هذه الأجهزة من الكل (مفصولة بفواصل)",
|
||||
"extended_entity_discovery": "أضف أجهزة استشعار ومفاتيح وأضواء إضافية",
|
||||
"hass_url": "رابط الشبكة المحلية للوصول إلى Home Assistant",
|
||||
"include_devices": "تضمين هذه الأجهزة فقط (مفصولة بفواصل)",
|
||||
"otp_secret": "مفتاح تطبيق المصادقة المؤلف من 52 حرفًا للتحقق الثنائي من أمازون",
|
||||
"password": "كلمة المرور",
|
||||
"public_url": "رابط عام مشترك مع خدمات مستضافة خارجية",
|
||||
"queue_delay": "تأخير وضع أوامر متعددة في قائمة الانتظار معًا (بالثواني)",
|
||||
"scan_interval": "الفاصل الزمني للإستطلاع المجدول (بالثواني)",
|
||||
"securitycode": "كلمة مرور لمرة واحدة (OTP)",
|
||||
"should_get_network": "اكتشف شبكة اليكسا",
|
||||
"url": "نطاق منطقة Amazon (على سبيل المثال، amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Carbon monoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Indoor air quality"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Particulate matter"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Next reminder"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Next timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repeat"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "تم إيقاف استخدام ملف YAML لتكوين مشغل وسائط Alexa.\n\nيرجى إزالة `alexa_media` من ملف التكوين، وإعادة تشغيل Home Assistant، واستخدام واجهة المستخدم لتكوينه.\n\nالإعدادات > الأجهزة والخدمات > التكاملات > إضافة تكامل",
|
||||
"title": "إعدادات YAML غير معتمد"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "تصحيح الأخطاء المتقدم",
|
||||
"exclude_devices": "أو استبعاد هذه الأجهزة من الكل (مفصولة بفواصل)",
|
||||
"extended_entity_discovery": "أضف أجهزة استشعار ومفاتيح وأضواء إضافية",
|
||||
"include_devices": "تضمين هذه الأجهزة فقط (مفصولة بفواصل)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"public_url": "رابط عام مشترك مع خدمات مستضافة خارجية",
|
||||
"queue_delay": "تأخير وضع أوامر متعددة في قائمة الانتظار معًا (بالثواني)",
|
||||
"scan_interval": "تكرار الاستطلاع المجدول (بالثواني)",
|
||||
"should_get_network": "اكتشف شبكة اليكسا"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* إدخالات مطلوبة",
|
||||
"title": "Alexa Media Player - إعادة التكوين"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "يعيد تفعيل اكتشاف شبكة Alexa بحيث تعيد دورة الاستطلاع التالية اكتشاف أجهزة Alexa للحسابات المحددة.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "بريد إلكتروني اختياري لحساب أليكسا أو قائمة عناوين البريد الإلكتروني. في حال عدم وجودها، سيتم تحديث جميع الحسابات المعروفة.",
|
||||
"name": "عنوان البريد الإلكتروني"
|
||||
}
|
||||
},
|
||||
"name": "تفعيل اكتشاف الشبكة"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "إجبار الحساب على تسجيل الخروج. يُستخدم بشكل أساسي لأغراض التصحيح.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "الحسابات المراد مسحها. إذا كانت فارغة سيتم مسح الكل.",
|
||||
"name": "البريد الإلكتروني"
|
||||
}
|
||||
},
|
||||
"name": "فرض تسجيل الخروج"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "يقوم بتحليل سجلات التاريخ للجهاز المحدد",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "الكيان الذي سيتم الحصول منه على السجل",
|
||||
"name": "حدد مشغل الوسائط:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "عدد الإدخالات المطلوب الحصول عليها",
|
||||
"name": "عدد الإدخالات"
|
||||
}
|
||||
},
|
||||
"name": "الحصول على سجلات التاريخ"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "استعادة مستوى الصوت السابق على جهاز Alexa media player",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "العنصر لاستعادة مستوى الصوت السابق عليه",
|
||||
"name": "اختر مشغل الوسائط:"
|
||||
}
|
||||
},
|
||||
"name": "استعادة مستوى الصوت السابق"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "فرض التحديث لـ \"آخر إتصال\" من جهاز echo لجميع حسابات Alexa.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "قائمة حسابات Alexa للتحديث. إذا كانت فارغة، سيتم تحديث جميع الحسابات المعروفة.",
|
||||
"name": "البريد الإلكتروني"
|
||||
}
|
||||
},
|
||||
"name": "تحديث مستشعر \"آخر إتصال\""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "The Forgot Password page was detected. This normally is the result of too many failed logins. Amazon may require action before a relogin can be attempted.",
|
||||
"login_failed": "Alexa Media Player failed to login.",
|
||||
"reauth_successful": "Alexa Media Player successfully reauthenticated. Please ignore the \"Aborted\" message from HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} is invalid",
|
||||
"connection_error": "Error connecting; check network and retry",
|
||||
"identifier_exists": "Email for Alexa URL already registered",
|
||||
"invalid_auth": "Login was not successful. Please double-check your email, password, and Authenticator key.",
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"invalid_url": "URL is invalid: {message}",
|
||||
"oauth_error": "Could not complete OAuth login. Please try again.",
|
||||
"unable_to_connect_hass_url": "Unable to connect to Home Assistant Local URL. Please check the URL under Settings > System > Network > Home Assistant URL > Local network",
|
||||
"unknown_error": "Unknown error: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignore and Continue - I understand that no support for login issues are provided for bypassing this warning."
|
||||
},
|
||||
"description": "The HA server cannot connect to the URL provided: {hass_url}.\n> {error}\n\nTo fix this, please confirm your browser can reach {hass_url}. This field is from Settings > System > Network > Home Assistant URL.\n\nIf you are **certain** your browser can reach this URL, you can bypass this warning.",
|
||||
"title": "Alexa Media Player - Unable to Connect to HA URL"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Yes, OTP code was verified"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \nHave you verified the OTP code in Amazon 2SV? \n >OTP Code: {message}",
|
||||
"title": "Alexa Media Player - OTP Confirmation"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"email": "Email Address",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"hass_url": "Local network URL to access Home Assistant",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"password": "Password",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"securitycode": "One-time password (OTP)",
|
||||
"should_get_network": "Discover Alexa network",
|
||||
"url": "Amazon region domain (e.g., amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Carbon monoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Indoor air quality"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Particulate matter"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Next reminder"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Next timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repeat"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "YAML configuration of Alexa Media Player is deprecated.\nPlease remove `alexa_media` from your configuration, restart Home Assistant and use the UI to configure it instead.\nSettings > Devices & services > Integrations > ADD INTEGRATION",
|
||||
"title": "YAML configuration is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"should_get_network": "Discover Alexa network"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Required entry",
|
||||
"title": "Alexa Media Player - Reconfiguration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Re-enables Alexa network discovery so the next polling cycle will rediscover Alexa devices for the selected accounts.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Optional Alexa account email or list of emails. If empty, all known accounts will be refreshed.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Enable Network Discovery"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Force account to logout. Used mainly for debugging.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Accounts to clear. Empty will clear all.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Force Logout"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Parses the history records for the specified device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entity to get the history for",
|
||||
"name": "Select media player:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Number of entries to get",
|
||||
"name": "Number of entries"
|
||||
}
|
||||
},
|
||||
"name": "Get History Records"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Restore previous volume level on Alexa media player device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entity to restore the previous volume level on",
|
||||
"name": "Select media player:"
|
||||
}
|
||||
},
|
||||
"name": "Restore Previous Volume"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Forces update of last_called echo device for each Alexa account.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "List of Alexa accounts to update. If empty, will update all known accounts.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Update Last Called Sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "The Forgot Password page was detected. This normally is the result of too many failed logins. Amazon may require action before a relogin can be attempted.",
|
||||
"login_failed": "Alexa Media Player failed to login.",
|
||||
"reauth_successful": "Alexa Media Player successfully reauthenticated. Please ignore the \"Aborted\" message from HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} is invalid",
|
||||
"connection_error": "Error connecting; check network and retry",
|
||||
"identifier_exists": "Email for Alexa URL already registered",
|
||||
"invalid_auth": "Login was not successful. Please double-check your email, password, and Authenticator key.",
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"invalid_url": "URL is invalid: {message}",
|
||||
"oauth_error": "Could not complete OAuth login. Please try again.",
|
||||
"unable_to_connect_hass_url": "Unable to connect to Home Assistant Local URL. Please check the URL under Settings > System > Network > Home Assistant URL > Local network",
|
||||
"unknown_error": "Unknown error: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignore and Continue - I understand that no support for login issues are provided for bypassing this warning."
|
||||
},
|
||||
"description": "The HA server cannot connect to the URL provided: {hass_url}.\n> {error}\n\nTo fix this, please confirm your browser can reach {hass_url}. This field is from Settings > System > Network > Home Assistant URL.\n\nIf you are **certain** your browser can reach this URL, you can bypass this warning.",
|
||||
"title": "Alexa Media Player - Unable to Connect to HA URL"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Yes, OTP code was verified"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \nHave you verified the OTP code in Amazon 2SV? \n >OTP Code: {message}",
|
||||
"title": "Alexa Media Player - OTP Confirmation"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"email": "Email Address",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"hass_url": "Local network URL to access Home Assistant",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"password": "Password",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"securitycode": "One-time password (OTP)",
|
||||
"should_get_network": "Discover Alexa network",
|
||||
"url": "Amazon region domain (e.g., amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Carbon monoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Indoor air quality"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Particulate matter"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Next reminder"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Next timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repeat"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "YAML configuration of Alexa Media Player is deprecated.\nPlease remove `alexa_media` from your configuration, restart Home Assistant and use the UI to configure it instead.\nSettings > Devices & services > Integrations > ADD INTEGRATION",
|
||||
"title": "YAML configuration is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"should_get_network": "Discover Alexa network"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Required entry",
|
||||
"title": "Alexa Media Player - Reconfiguration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Re-enables Alexa network discovery so the next polling cycle will rediscover Alexa devices for the selected accounts.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Optional Alexa account email or list of emails. If empty, all known accounts will be refreshed.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Enable Network Discovery"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Force account to logout. Used mainly for debugging.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Accounts to clear. Empty will clear all.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Force Logout"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Parses the history records for the specified device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entity to get the history for",
|
||||
"name": "Select media player:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Number of entries to get",
|
||||
"name": "Number of entries"
|
||||
}
|
||||
},
|
||||
"name": "Get History Records"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Restore previous volume level on Alexa media player device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entity to restore the previous volume level on",
|
||||
"name": "Select media player:"
|
||||
}
|
||||
},
|
||||
"name": "Restore Previous Volume"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Forces update of last_called echo device for each Alexa account.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "List of Alexa accounts to update. If empty, will update all known accounts.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Update Last Called Sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "Die Seite 'Passwort vergessen' wurde erkannt. Dies ist normalerweise das Ergebnis zu vieler fehlgeschlagener Anmeldeversuche. Amazon könnte eine Aktion verlangen, bevor ein erneuter Login versucht werden kann.",
|
||||
"login_failed": "Alexa Media Player konnte nicht angemeldet werden.",
|
||||
"reauth_successful": "Alexa Media Player wurde erfolgreich neu authentifiziert. Bitte ignorieren Sie die Meldung „Abgebrochen“ von Home Assistant."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} ist ungültig",
|
||||
"connection_error": "Verbindungsfehler; Netzwerk prüfen und erneut versuchen",
|
||||
"identifier_exists": "Diese E-Mail-Adresse ist bereits registriert",
|
||||
"invalid_auth": "Die Anmeldung ist fehlgeschlagen. Bitte überprüfen Sie Ihre E-Mail-Adresse, Ihr Passwort und Ihren Authentifizierungsschlüssel.",
|
||||
"invalid_credentials": "Ungültige Zugangsdaten",
|
||||
"invalid_url": "URL ist ungültig: {message}",
|
||||
"oauth_error": "Die OAuth-Anmeldung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
"unable_to_connect_hass_url": "Es konnte keine Verbindung zur lokalen Home Assistant-URL hergestellt werden. Bitte überprüfen Sie die URL unter Einstellungen > System > Netzwerk > Home Assistant-URL > Lokales Netzwerk.",
|
||||
"unknown_error": "Unbekannter Fehler: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignorieren und Fortfahren - Ich verstehe, dass keine Unterstützung für Anmeldeprobleme beim Umgehen dieser Warnung angeboten wird."
|
||||
},
|
||||
"description": "Der HA-Server kann keine Verbindung zur bereitgestellten URL herstellen: {hass_url}.\n> {error}\n\nUm dies zu beheben, bestätigen Sie bitte, dass Ihr **HA-Server** {hass_url} erreichen kann. Dieses Feld stammt aus der externen URL unter Konfiguration -> Allgemein, aber Sie können auch Ihre interne URL ausprobieren.\n\nWenn Sie **sicher** sind, dass Ihr Client diese URL erreichen kann, können Sie diese Warnung ignorieren und fortsetzen.",
|
||||
"title": "Alexa Media Player - Keine Verbindung zur Home Assistant-URL möglich"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Ja, der OTP-Code wurde verifiziert."
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \nHaben Sie erfolgreich einen OTP-Code aus dem integrierten 2FA-App-Schlüssel mit Amazon bestätigt?\n >OTP-Code {message}",
|
||||
"title": "Alexa Media Player - OTP-Bestätigung"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Erweitertes Debugging",
|
||||
"email": "E-Mail-Adresse",
|
||||
"exclude_devices": "oder Diese Geräte von allen ausschließen (durch Komma getrennt)",
|
||||
"extended_entity_discovery": "Fügen Sie zusätzliche Sensoren, Schalter und Leuchten hinzu.",
|
||||
"hass_url": "Lokale Netzwerk-URL für den Zugriff auf Home Assistant",
|
||||
"include_devices": "Eingebundene Geräte (Komma getrennt)",
|
||||
"otp_secret": "52-stelliger Authenticator-App Schlüssel für Amazon 2SV",
|
||||
"password": "Passwort",
|
||||
"public_url": "Öffentliche URL, die mit extern gehosteten Diensten geteilt wird",
|
||||
"queue_delay": "Verzögerung beim Zusammenführen mehrerer Befehle in einer Warteschlange (Sekunden)",
|
||||
"scan_interval": "Geplantes Abfrageintervall (Sekunden)",
|
||||
"securitycode": "Einmalpasswort (OTP)",
|
||||
"should_get_network": "Entdecken Sie das Alexa-Netzwerk",
|
||||
"url": "Amazon Region (z.B. amazon.de)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Ermöglicht eine sehr ausführliche Protokollierung auf Trace-Ebene für die erweiterte Fehlerbehebung. \n Aufgrund des erhöhten Protokollvolumens wird dies für den Normalbetrieb nicht empfohlen. \n Stellen Sie sicher, dass die Protokollierungsstufe auf DEBUG eingestellt ist, um die vollständige Ausgabe zu erhalten.",
|
||||
"otp_secret": "Beispiel: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Luftqualität"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Kohlenmonoxid"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Luftfeuchtigkeit"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Innenraumluftqualität"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Feinstaub"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Flüchtige organische Verbindungen"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Nächster Alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Nächste Erinnerung"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Nächster Timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperatur"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Bitte nicht stören"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Wiederholen"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "Die YAML-Konfiguration des Alexa Media Players ist veraltet.\nBitte entfernen Sie `alexa_media` aus Ihrer Konfiguration, starten Sie Home Assistant neu und verwenden Sie stattdessen die Benutzeroberfläche zur Konfiguration.\nEinstellungen > Geräte & Dienste > Integrationen > INTEGRATION HINZUFÜGEN",
|
||||
"title": "Die YAML-Konfiguration ist veraltet"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Erweitertes Debugging",
|
||||
"exclude_devices": "oder diese Geräte von allen ausschließen (durch Komma getrennt)",
|
||||
"extended_entity_discovery": "Fügen Sie zusätzliche Sensoren, Schalter und Leuchten hinzu.",
|
||||
"include_devices": "Nur diese Geräte angeben (durch Komma getrennt)",
|
||||
"otp_secret": "52-stelliger Authenticator-App-Schlüssel für Amazon 2SV",
|
||||
"public_url": "Öffentliche URL, die mit extern gehosteten Diensten geteilt wird",
|
||||
"queue_delay": "Verzögerung beim Zusammenführen mehrerer Befehle in die Warteschlange (Sekunden)",
|
||||
"scan_interval": "Geplante Abfragehäufigkeit (Sekunden)",
|
||||
"should_get_network": "Entdecken Sie das Alexa-Netzwerk"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Ermöglicht eine sehr ausführliche Protokollierung auf Trace-Ebene für die erweiterte Fehlerbehebung. \n Aufgrund des erhöhten Protokollvolumens wird dies für den Normalbetrieb nicht empfohlen. \n Stellen Sie sicher, dass die Protokollierungsstufe auf DEBUG eingestellt ist, um die vollständige Ausgabe zu erhalten.",
|
||||
"otp_secret": "Beispiel: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Erforderliche Angabe",
|
||||
"title": "Alexa Media Player - Rekonfiguration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Aktiviert die Alexa-Netzwerkerkennung erneut, sodass beim nächsten Abfragezyklus die Alexa-Geräte für die ausgewählten Konten erneut erkannt werden.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Optionale E-Mail-Adresse oder Liste von E-Mail-Adressen Ihres Alexa-Kontos. Falls leer, werden alle bekannten Konten aktualisiert.",
|
||||
"name": "E-Mail-Adresse"
|
||||
}
|
||||
},
|
||||
"name": "Aktivieren Sie die Netzwerkerkennung"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Logout erzwingen. Primär für Debugging genutzt.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Zu löschende Accounts. Falls leer, werden alle gelöscht.",
|
||||
"name": "E-Mail-Adresse"
|
||||
}
|
||||
},
|
||||
"name": "Logout erzwingen"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analysiert die Verlaufsdatensätze für das angegebene Gerät",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entität, für die der Verlauf abgerufen werden soll",
|
||||
"name": "Mediaplayer auswählen:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Anzahl der abzurufenden Einträge",
|
||||
"name": "Anzahl der Einträge"
|
||||
}
|
||||
},
|
||||
"name": "Verlaufsdatensätze abrufen"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Vorherige Lautstärke auf dem Alexa-Mediaplayer-Gerät wiederherstellen",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entität zum Wiederherstellen der vorherigen Lautstärke auf",
|
||||
"name": "Mediaplayer auswählen:"
|
||||
}
|
||||
},
|
||||
"name": "Vorherige Lautstärke wiederherstellen"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Erzwingt eine Aktualisierung des zuletzt aufgerufenen Echo-Geräts für jedes Alexa-Konto.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Liste der zu aktualisierenden Alexa-Konten. Wenn leer, werden alle bekannten Konten aktualisiert.",
|
||||
"name": "E-Mail-Adresse"
|
||||
}
|
||||
},
|
||||
"name": "Aktualisiere den zuletzt aufgerufenen Sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "The Forgot Password page was detected. This normally is the result of too many failed logins. Amazon may require action before a relogin can be attempted.",
|
||||
"login_failed": "Alexa Media Player failed to login.",
|
||||
"reauth_successful": "Alexa Media Player successfully reauthenticated. Please ignore the \"Aborted\" message from HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} is invalid",
|
||||
"connection_error": "Error connecting; check network and retry",
|
||||
"identifier_exists": "Email for Alexa URL already registered",
|
||||
"invalid_auth": "Login was not successful. Please double-check your email, password, and Authenticator key.",
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"invalid_url": "URL is invalid: {message}",
|
||||
"oauth_error": "Could not complete OAuth login. Please try again.",
|
||||
"unable_to_connect_hass_url": "Unable to connect to Home Assistant Local URL. Please check the URL under Settings > System > Network > Home Assistant URL > Local network",
|
||||
"unknown_error": "Unknown error: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignore and Continue - I understand that no support for login issues are provided for bypassing this warning."
|
||||
},
|
||||
"description": "The HA server cannot connect to the URL provided: {hass_url}.\n> {error}\n\nTo fix this, please confirm your browser can reach {hass_url}. This field is from Settings > System > Network > Home Assistant URL.\n\nIf you are **certain** your browser can reach this URL, you can bypass this warning.",
|
||||
"title": "Alexa Media Player - Unable to Connect to HA URL"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Yes, OTP code was verified"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \nHave you verified the OTP code in Amazon 2SV? \n >OTP Code: {message}",
|
||||
"title": "Alexa Media Player - OTP Confirmation"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"email": "Email Address",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"hass_url": "Local network URL to access Home Assistant",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"password": "Password",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"securitycode": "One-time password (OTP)",
|
||||
"should_get_network": "Discover Alexa network",
|
||||
"url": "Amazon region domain (e.g., amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Carbon monoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Indoor air quality"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Particulate matter"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Next reminder"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Next timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repeat"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "YAML configuration of Alexa Media Player is deprecated.\nPlease remove `alexa_media` from your configuration, restart Home Assistant and use the UI to configure it instead.\nSettings > Devices & services > Integrations > ADD INTEGRATION",
|
||||
"title": "YAML configuration is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"should_get_network": "Discover Alexa network"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Required entry",
|
||||
"title": "Alexa Media Player - Reconfiguration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Re-enables Alexa network discovery so the next polling cycle will rediscover Alexa devices for the selected accounts.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Optional Alexa account email or list of emails. If empty, all known accounts will be refreshed.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Enable Network Discovery"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Force account to logout. Used mainly for debugging.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Accounts to clear. Empty will clear all.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Force Logout"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Parses the history records for the specified device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entity to get the history for",
|
||||
"name": "Select media player:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Number of entries to get",
|
||||
"name": "Number of entries"
|
||||
}
|
||||
},
|
||||
"name": "Get History Records"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Restore previous volume level on Alexa media player device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entity to restore the previous volume level on",
|
||||
"name": "Select media player:"
|
||||
}
|
||||
},
|
||||
"name": "Restore Previous Volume"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Forces update of last_called echo device for each Alexa account.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "List of Alexa accounts to update. If empty, will update all known accounts.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Update Last Called Sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "Se detectó la página de Olvidé mi contraseña. Normalmente, esto es el resultado de demasiados intentos fallidos de inicio de sesión. Amazon puede requerir acción antes de que se pueda intentar iniciar sesión nuevamente.",
|
||||
"login_failed": "Alexa Media Player no pudo iniciar sesión.",
|
||||
"reauth_successful": "Alexa Media Player se volvió a autenticar con éxito. Ignore el mensaje \"Cancelado\" de HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} no es válido",
|
||||
"connection_error": "Error al conectar, verifique la red y vuelva a intentarlo",
|
||||
"identifier_exists": "Correo electrónico para la URL de Alexa ya registrado",
|
||||
"invalid_auth": "No se pudo iniciar sesión correctamente. Por favor, revise su correo electrónico, contraseña y clave de autenticación.",
|
||||
"invalid_credentials": "Credenciales no válidas",
|
||||
"invalid_url": "La URL no es válida: {message}",
|
||||
"oauth_error": "No se pudo completar el inicio de sesión de OAuth. Inténtalo de nuevo.",
|
||||
"unable_to_connect_hass_url": "No se puede conectar a la URL local de Home Assistant. Verifique la URL en Ajustes > Sistema > Red > URL de Home Assistant > Red local.",
|
||||
"unknown_error": "Error desconocido: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignorar y continuar: entiendo que no se proporciona soporte para problemas de inicio de sesión para eludir esta advertencia."
|
||||
},
|
||||
"description": "El servidor HA no puede conectarse a la URL proporcionada: {hass_url}.\n> {error}\n\nPara solucionar esto, confirme que su navegador pueda acceder a {hass_url}. Este campo se encuentra en Ajustes > Sistema > Red > URL de Home Assistant.\n\nSi está **seguro** de que su navegador puede acceder a esta URL, puede omitir esta advertencia.",
|
||||
"title": "Alexa Media Player: no se puede conectar a la URL de alta disponibilidad"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Sí, el código OTP fue verificado"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \n¿Has verificado el código OTP en Amazon 2SV?\n>Código OTP: {message}",
|
||||
"title": "Alexa Media Player - OTP Confirmación"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Depuración avanzada",
|
||||
"email": "Dirección de correo electrónico",
|
||||
"exclude_devices": "o Excluir estos dispositivos de todos (separados por comas)",
|
||||
"extended_entity_discovery": "Incluye sensores, interruptores y luces adicionales.",
|
||||
"hass_url": "URL de red local para acceder a Home Assistant",
|
||||
"include_devices": "Incluya solo estos dispositivos (separados por comas)",
|
||||
"otp_secret": "Clave de aplicación de autenticación de 52 caracteres para la verificación en dos pasos de Amazon",
|
||||
"password": "Contraseña",
|
||||
"public_url": "URL pública compartida con servicios alojados externos",
|
||||
"queue_delay": "Retraso para poner en cola varios comandos juntos (segundos)",
|
||||
"scan_interval": "Intervalo de sondeo programado (segundos)",
|
||||
"securitycode": "Contraseña de un solo uso (OTP)",
|
||||
"should_get_network": "Descubra la red Alexa",
|
||||
"url": "Región del dominio de Amazon (por ejemplo, amazon.es)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Habilita un registro de nivel de seguimiento muy detallado para la resolución de problemas avanzada. \n No se recomienda para el funcionamiento normal debido al aumento del volumen de registro. \n Asegúrese de que los niveles del registrador estén configurados en DEBUG para obtener una salida completa.",
|
||||
"otp_secret": "Ejemplo: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Calidad del aire"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Monóxido de carbono"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humedad"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Calidad del aire interior"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "materia particulada"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Compuestos orgánicos volátiles"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Próxima alarma"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Próximo recordatorio"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Próximo temporizador"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperatura"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "No molestar"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repetir"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Barajar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "La configuración YAML de Alexa Media Player está obsoleta.\nElimina `alexa_media` de tu configuración, reinicia Home Assistant y usa la interfaz de usuario para configurarlo.\nAjustes > Dispositivos y servicios > Integraciones > AÑADIR INTEGRACIÓN",
|
||||
"title": "La configuración de YAML está obsoleta"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Depuración avanzada",
|
||||
"exclude_devices": "o Excluir estos dispositivos de todos (separados por comas)",
|
||||
"extended_entity_discovery": "Incluye sensores, interruptores y luces adicionales.",
|
||||
"include_devices": "Incluya solo estos dispositivos (separados por comas)",
|
||||
"otp_secret": "Clave de aplicación de autenticación de 52 caracteres para la verificación en dos pasos de Amazon",
|
||||
"public_url": "URL pública compartida con servicios alojados externos",
|
||||
"queue_delay": "Retraso para poner en cola varios comandos juntos (segundos)",
|
||||
"scan_interval": "Frecuencia de sondeo programada (segundos)",
|
||||
"should_get_network": "Descubra la red Alexa"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Habilita un registro de nivel de seguimiento muy detallado para la resolución de problemas avanzada. \n No se recomienda para el funcionamiento normal debido al aumento del volumen de registro. \n Asegúrese de que los niveles del registrador estén configurados en DEBUG para obtener una salida completa.",
|
||||
"otp_secret": "Ejemplo: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Entradas obligatorias",
|
||||
"title": "Alexa Media Player - Reconfiguración"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Vuelve a habilitar el descubrimiento de red de Alexa para que el próximo ciclo de sondeo redescubra los dispositivos Alexa para las cuentas seleccionadas.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Correo electrónico o lista de correos electrónicos de la cuenta de Alexa (opcional). Si está vacío, se actualizarán todas las cuentas conocidas.",
|
||||
"name": "Dirección de correo electrónico"
|
||||
}
|
||||
},
|
||||
"name": "Habilitar el descubrimiento de red"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Obligar el cierre de sesión de la cuenta. Usar principalmente para depuración.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Cuentas a borrar. Si se deja vacío se borraran todas.",
|
||||
"name": "Dirección de correo electrónico"
|
||||
}
|
||||
},
|
||||
"name": "Obligar cierre de sesión"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analiza los registros del historial del dispositivo especificado",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entidad para obtener el historial",
|
||||
"name": "Seleccionar reproductor multimedia:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Número de entradas a obtener",
|
||||
"name": "Número de entradas"
|
||||
}
|
||||
},
|
||||
"name": "Obtener registros históricos"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Restaurar el nivel de volumen anterior en el reproductor multimedia Alexa",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entidad para restaurar el nivel de volumen anterior",
|
||||
"name": "Seleccionar reproductor multimedia:"
|
||||
}
|
||||
},
|
||||
"name": "Restaurar volumen anterior"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Obligar la actualización del último dispositivo Echo llamado para cada cuenta Alexa.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Cuentas de Alexa para actualizar. Si se deja vacío, se actualizaran todas las cuentas.",
|
||||
"name": "Dirección de correo electrónico"
|
||||
}
|
||||
},
|
||||
"name": "Actualizar el último sensor utilizado"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"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."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} n'est pas valide",
|
||||
"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}",
|
||||
"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}"
|
||||
},
|
||||
"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.",
|
||||
"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}",
|
||||
"title": "Alexa Media Player - Confirmation OTP"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Débogage avancé",
|
||||
"email": "Adresse e-mail",
|
||||
"exclude_devices": "ou Exclure ces appareils (séparés par des virgules)",
|
||||
"extended_entity_discovery": "Inclure les capteurs, interrupteurs et lumières additionnels",
|
||||
"hass_url": "URL du réseau local pour accéder à Home Assistant",
|
||||
"include_devices": "Inclure uniquement ces appareils (séparés par des virgules)",
|
||||
"otp_secret": "Clé d'authentification à 52 caractères pour Amazon 2SV",
|
||||
"password": "Mot de passe",
|
||||
"public_url": "URL publique partagée avec les services externes hébergés",
|
||||
"queue_delay": "Délai pour regrouper plusieurs commandes (secondes)",
|
||||
"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)"
|
||||
},
|
||||
"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.",
|
||||
"otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "qualité de l'air"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Monoxyde de carbone"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidité"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "qualité de l'air intérieur"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Matières particulaires"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Composés organiques volatils"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Prochaine alarme"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Prochain rappel"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "La prochaine fois"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Température"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Ne pas déranger"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Répéter"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Mélanger"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "La configuration YAML d'Alexa Media Player est obsolète.\nVeuillez supprimer `alexa_media` de votre configuration, redémarrer Home Assistant et utiliser l'interface utilisateur pour la configurer à la place.\nParamètres > Appareils et services > Intégrations > AJOUTER UNE INTÉGRATION",
|
||||
"title": "La configuration YAML est obsolète"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Débogage avancé",
|
||||
"exclude_devices": "ou Exclure ces appareils (séparés par des virgules)",
|
||||
"extended_entity_discovery": "Inclure les capteurs, interrupteurs et lumières additionnels",
|
||||
"include_devices": "Inclure uniquement ces appareils (séparés par des virgules)",
|
||||
"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)",
|
||||
"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.",
|
||||
"otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Champs obligatoires",
|
||||
"title": "Alexa Media Player - Reconfiguration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Réactive la découverte du réseau Alexa afin que le prochain cycle d'interrogation redécouvre les appareils Alexa pour les comptes sélectionnés.",
|
||||
"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": "Activer la découverte du réseau"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Force la déconnexion du compte. Utilisé principalement pour le débogage.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Comptes à effacer. Laisser vide effacera tous les comptes.",
|
||||
"name": "Adresse e-mail"
|
||||
}
|
||||
},
|
||||
"name": "Forcer la déconnexion"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analyse les enregistrements d'historique pour l'appareil spécifié.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entité pour laquelle obtenir l'historique",
|
||||
"name": "Sélectionner le lecteur multimédia:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Nombre d'entrées à récupérer",
|
||||
"name": "Nombre d'entrées"
|
||||
}
|
||||
},
|
||||
"name": "Obtenir les enregistrements d'historique"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Restaure le niveau de volume précédent sur l'appareil Alexa Media Player.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entité sur laquelle restaurer le niveau de volume précédent",
|
||||
"name": "Sélectionner le lecteur multimédia:"
|
||||
}
|
||||
},
|
||||
"name": "Restaurer le volume précédent"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Force la mise à jour du dernier appareil Echo appelé pour chaque compte Alexa.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Liste des comptes Alexa à mettre à jour. Si vide, tous les comptes connus seront mis à jour.",
|
||||
"name": "Adresse e-mail"
|
||||
}
|
||||
},
|
||||
"name": "Mettre à jour le capteur du dernier appel"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "La pagina Password Dimenticata è stata rilevata. Questo normalmente è il risultato di troppi tentativi di accesso falliti. Amazon potrebbe richiedere un'azione prima di poter tentare nuovamente il login.",
|
||||
"login_failed": "Alexa Media Player ha fallito il login.",
|
||||
"reauth_successful": "Alexa Media Player è stato riautenticato con successo. Ignorare il messaggio \"Abortito\" da HA"
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} non è valido",
|
||||
"connection_error": "Errore durante la connessione; controlla la rete e riprova",
|
||||
"identifier_exists": "L'email per l'URL di Alexa è già stata registrata",
|
||||
"invalid_auth": "Accesso non riuscito. Controlla nuovamente la tua email, la password e la chiave di autenticazione.",
|
||||
"invalid_credentials": "Credenziali non valide",
|
||||
"invalid_url": "URL non valido: {message}",
|
||||
"oauth_error": "Impossibile completare l'accesso OAuth. Riprova.",
|
||||
"unable_to_connect_hass_url": "Impossibile connettersi all'URL locale di Home Assistant. Controllare l'URL in Impostazioni > Sistema > Rete > URL di Home Assistant > Rete locale",
|
||||
"unknown_error": "Errore sconosciuto: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignora e continua - capisco che non verrà fornito alcun supporto per i problemi di accesso derivanti dall'aggirare questo avviso."
|
||||
},
|
||||
"description": "Il server HA non riesce a connettersi all'URL fornito: {hass_url}.\n> {error}\n\nPer risolvere questo problema, verifica che il tuo browser possa raggiungere {hass_url}. Questo campo si trova in Impostazioni > Sistema > Rete > URL Home Assistant.\n\nSe sei **certo** che il tuo browser possa raggiungere questo URL, puoi ignorare questo avviso.",
|
||||
"title": "Alexa Media Player - Impossibile connettersi all'URL HA"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Sì, il codice OTP è stato verificato"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}**\nHai verificato il codice OTP in Amazon 2SV?\n>Codice OTP {message}",
|
||||
"title": "Alexa Media Player - Conferma OTP"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Debug avanzato",
|
||||
"email": "Indirizzo email",
|
||||
"exclude_devices": "o Escludi questi dispositivi da tutti (separati da virgole)",
|
||||
"extended_entity_discovery": "Includere sensori, interruttori e luci aggiuntivi",
|
||||
"hass_url": "URL della rete locale per accedere a Home Assistant",
|
||||
"include_devices": "Includi solo questi dispositivi (separati da virgole)",
|
||||
"otp_secret": "Chiave da 52 caratteri dell'app Authenticator per il 2SV di Amazon",
|
||||
"password": "Password",
|
||||
"public_url": "URL pubblico condiviso con servizi ospitati esterni",
|
||||
"queue_delay": "Ritardo per mettere in coda più comandi contemporaneamente (secondi)",
|
||||
"scan_interval": "Frequenza di sondaggio pianificata (secondi)",
|
||||
"securitycode": "Password monouso (OTP)",
|
||||
"should_get_network": "Scopri la rete Alexa",
|
||||
"url": "Regione del dominio Amazon (ad es., amazon.it)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Abilita la registrazione molto dettagliata a livello di traccia per la risoluzione avanzata dei problemi. \n Non consigliato per il normale funzionamento a causa dell'aumento del volume di registro. \n Assicurarsi che i livelli del logger siano impostati su DEBUG per un output completo.",
|
||||
"otp_secret": "Esempio: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Qualità dell'aria"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "monossido di carbonio"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Umidità"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Qualità dell'aria interna"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "particolato"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Composti organici volatili"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Prossimo allarme"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Prossimo promemoria"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Prossimo timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperatura"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Non disturbare"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Ripetere"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Mescolare"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "La configurazione YAML di Alexa Media Player è obsoleta.\nRimuovi `alexa_media` dalla configurazione, riavvia Home Assistant e utilizza l'interfaccia utente per configurarla.\nImpostazioni > Dispositivi e servizi > Integrazioni > AGGIUNGI INTEGRAZIONE",
|
||||
"title": "La configurazione YAML è deprecata"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Debug avanzato",
|
||||
"exclude_devices": "o Escludi questi dispositivi da tutti (separati da virgole)",
|
||||
"extended_entity_discovery": "Includere sensori, interruttori e luci aggiuntivi",
|
||||
"include_devices": "Includi solo questi dispositivi (separati da virgole)",
|
||||
"otp_secret": "Chiave da 52 caratteri dell'app Authenticator per il 2SV di Amazon",
|
||||
"public_url": "URL pubblico condiviso con servizi ospitati esterni",
|
||||
"queue_delay": "Ritardo per mettere in coda più comandi contemporaneamente (secondi)",
|
||||
"scan_interval": "Frequenza di sondaggio pianificata (secondi)",
|
||||
"should_get_network": "Scopri la rete Alexa"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Abilita la registrazione molto dettagliata a livello di traccia per la risoluzione avanzata dei problemi. \n Non consigliato per il normale funzionamento a causa dell'aumento del volume di registro. \n Assicurarsi che i livelli del logger siano impostati su DEBUG per un output completo.",
|
||||
"otp_secret": "Esempio: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Voci obbligatorie",
|
||||
"title": "Alexa Media Player - Riconfigurazione"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Riattiva la rilevazione della rete Alexa in modo che il successivo ciclo di sondaggio rilevi i dispositivi Alexa per gli account selezionati.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Indirizzo email dell'account Alexa facoltativo o elenco d'indirizzi email. Se vuoto, tutti gli account noti verranno aggiornati.",
|
||||
"name": "Indirizzo email"
|
||||
}
|
||||
},
|
||||
"name": "Abilita rilevamento rete"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Forza logout dell'account. Usato principalmente per il debugging.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Account da eliminare. Se vuoto, verranno cancellati tutti.",
|
||||
"name": "Indirizzo email"
|
||||
}
|
||||
},
|
||||
"name": "Forza Logout"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analizza i record cronologici per il dispositivo specificato",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entità per cui ottenere la cronologia",
|
||||
"name": "Seleziona lettore multimediale:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Numero di voci da ottenere",
|
||||
"name": "Numero di voci"
|
||||
}
|
||||
},
|
||||
"name": "Ottieni i record della cronologia"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Ripristina il livello del volume precedente sul dispositivo lettore multimediale Alexa",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entità per ripristinare il livello del volume precedente",
|
||||
"name": "Seleziona lettore multimediale:"
|
||||
}
|
||||
},
|
||||
"name": "Ripristina il volume precedente"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Forza l'aggiornamento del dispositivo echo last_called per ogni account Alexa.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Lista di account Alexa da aggiornare. Se vuoto, verranno aggiornati tutti.",
|
||||
"name": "Indirizzo email"
|
||||
}
|
||||
},
|
||||
"name": "Aggiorna sensore last_called"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "「パスワードを忘れた場合」ページが検出されました。これは通常、ログインに何度も失敗した結果です。Amazonでは、再ログインを試みる前に対応を求める場合があります。",
|
||||
"login_failed": "Alexa Media Playerがログインに失敗しました。",
|
||||
"reauth_successful": "Alexa Media Playerは正常に再認証されました。Home Assistant からの \"Aborted\" メッセージは無視してください。"
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} は無効な認証アプリキーです",
|
||||
"connection_error": "接続エラー:ネットワークを確認して再試行してください",
|
||||
"identifier_exists": "Alexa URLに対するメールアドレスはすでに登録されています",
|
||||
"invalid_auth": "ログインに失敗しました。メールアドレス、パスワード、認証キーを再度ご確認ください。",
|
||||
"invalid_credentials": "無効な資格情報",
|
||||
"invalid_url": "URL が無効です:{message}",
|
||||
"oauth_error": "OAuthログインを完了できませんでした。もう一度お試しください。",
|
||||
"unable_to_connect_hass_url": "Home Assistant URL に接続できません。[設定] -> [全般] の [外部 URL] を確認してください。",
|
||||
"unknown_error": "不明なエラー:{message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "無視して続行 - この警告を回避することで、ログイン問題に関するサポートはされないことを承知しています。"
|
||||
},
|
||||
"description": "Home Assistant サーバーへ、指定された URL {hass_url}で接続できません。 \n> {error}\n\nこの問題を解決するには、あなたのHome Assistant サーバーに{hass_url}でアクセスできることを確認してください。このフィールドは、[設定] -> [全般] の [外部 URL] からのものですが、内部 URL を試すこともできます。クライアントがこの URL にアクセスできることが 確実であれば、この警告をバイパスできます。",
|
||||
"title": "Alexa Media Player - Home Assistant URLに接続できません"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "はい、OTP コードを確認しました。"
|
||||
},
|
||||
"description": "** {email} - alexa. {url} **\nAmazon 2段階認証で OTPコードを確認しましたか? \n>OTP コード: {message}",
|
||||
"title": "Alexa Media Player - OTP の確認"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "高度なデバッグ",
|
||||
"email": "メールアドレス",
|
||||
"exclude_devices": "除外するデバイス(カンマ区切り)",
|
||||
"extended_entity_discovery": "Echo経由で接続されたデバイスを含める",
|
||||
"hass_url": "Home AssistantにアクセスするためのURL",
|
||||
"include_devices": "含まれるデバイス(カンマ区切り)",
|
||||
"otp_secret": "Amazon 2段階認証用認証アプリキー(52桁)",
|
||||
"password": "パスワード",
|
||||
"public_url": "外部ホスティング・サービスと共有される公開URL",
|
||||
"queue_delay": "コマンドをキューにまとめて待機させる秒数",
|
||||
"scan_interval": "スキャン間隔秒数",
|
||||
"securitycode": "[%key_id:55616596%]",
|
||||
"should_get_network": "Alexaネットワークを探索",
|
||||
"url": "Amazon 地域ドメイン (例: amazon.co.jp)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Carbon monoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Indoor air quality"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Particulate matter"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Next reminder"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Next timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repeat"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "Alexa Media PlayerのYAML設定は非推奨であり、バージョン4.14.0で削除される予定です。 この設定の自動インポートは行われません。 設定から削除し、Home Assistantを再起動して、代わりにUIを使用して設定してください。 [設定] -> [デバイスとサービス] -> [統合] -> [統合を追加]",
|
||||
"title": "YAML設定は非推奨です"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "高度なデバッグ",
|
||||
"exclude_devices": "除外するデバイス(カンマ区切り)",
|
||||
"extended_entity_discovery": "Alexaデバイスに接続された追加のセンサー、スイッチ、ライトを含める",
|
||||
"include_devices": "含まれるデバイス(カンマ区切り)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"public_url": "Home Assistant にアクセスするための公開URL (末尾の '/' を含む)",
|
||||
"queue_delay": "コマンドをキューにまとめて待機させる秒数",
|
||||
"scan_interval": "スキャン間隔秒数",
|
||||
"should_get_network": "Alexaネットワークを探索"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* 必須エントリ\n注: **拡張エンティティ検出** を使用するには、**Alexa ネットワークの探索** を有効にする必要があります。",
|
||||
"title": "Alexa Media Player - 再設定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Alexa ネットワーク検出を再度有効にすると、次のポーリングサイクルで、選択したアカウントの Alexa デバイスが再検出されます。",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "オプションのalexaアカウントのメールアドレスまたはメールアドレスのリスト。空の場合、すべての既知のアカウントが更新されます。",
|
||||
"name": "Emailアドレス"
|
||||
}
|
||||
},
|
||||
"name": "ネットワーク探索を有効にする"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "アカウントを強制的にログアウトさせます (主にデバッグに使用します)",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "削除するアカウント 空にするとすべて削除されます",
|
||||
"name": "メールアドレス"
|
||||
}
|
||||
},
|
||||
"name": "強制ログアウト"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "指定したデバイスの履歴レコードを解析します",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "履歴を取得するエンティティ",
|
||||
"name": "メディアプレーヤーを選択:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "取得するエントリー数",
|
||||
"name": "エントリー数"
|
||||
}
|
||||
},
|
||||
"name": "履歴レコードを取得する"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Alexaメディアプレーヤーデバイスで以前の音量レベルを復元する",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "以前の音量レベルを復元するエンティティ",
|
||||
"name": "メディアプレーヤーを選択:"
|
||||
}
|
||||
},
|
||||
"name": "以前のボリュームを復元"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "各Alexaアカウントの最後に呼び出されたEchoデバイスを強制的に更新します。",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "更新する Alexa アカウントの一覧。空の場合、既知のすべてのアカウントが更新されます。",
|
||||
"name": "メールアドレス"
|
||||
}
|
||||
},
|
||||
"name": "最後に呼び出されたセンサーを更新"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "Glemt passord-siden ble oppdaget. Dette er vanligvis et resultat av for mange mislykkede påloggingsforsøk. Amazon kan kreve at du gjør noe før du kan prøve å logge inn igjen.",
|
||||
"login_failed": "Alexa Media Player kunne ikke logge inn.",
|
||||
"reauth_successful": "Alexa Media Player er autentisert på nytt. Vennligst ignorer meldingen «Avbrutt» fra HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} er ugyldig",
|
||||
"connection_error": "Feil ved tilkobling; sjekk nettverket og prøv på nytt",
|
||||
"identifier_exists": "E-post for Alexa URL allerede registrert",
|
||||
"invalid_auth": "Innloggingen mislyktes. Dobbeltsjekk e-post, passord og autentiseringsnøkkel.",
|
||||
"invalid_credentials": "ugyldige legitimasjon",
|
||||
"invalid_url": "URL er ugyldig: {message}",
|
||||
"oauth_error": "Kunne ikke fullføre OAuth-pålogging. Prøv på nytt.",
|
||||
"unable_to_connect_hass_url": "Kan ikke koble til den lokale URL-adressen for Home Assistant. Sjekk URL-adressen under Innstillinger > System > Nettverk > URL for Home Assistant > Lokalt nettverk",
|
||||
"unknown_error": "Ukjent feil: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignorer og fortsett – jeg forstår at det ikke gis støtte for innloggingsproblemer når denne advarselen omgås."
|
||||
},
|
||||
"description": "HA-serveren kan ikke koble til den oppgitte URL-en: {hass_url}.\n> {error}\n\nFor å fikse dette, må du bekrefte at nettleseren din kan nå {hass_url}. Dette feltet er fra Innstillinger > System > Nettverk > URL-adresse for Home Assistant.\n\nHvis du er **sikker** på at nettleseren din kan nå denne URL-en, kan du omgå denne advarselen.",
|
||||
"title": "Alexa Media Player – Kan ikke koble til HA URL"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Ja, engangskoden ble bekreftet"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \nHar du bekreftet engangskoden i Amazon 2SV?\n >OTP Kode {message}",
|
||||
"title": "Alexa Media Player - OTP bekreftelse"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Avansert feilsøking",
|
||||
"email": "Epostadresse",
|
||||
"exclude_devices": "eller Ekskluder disse enhetene fra alle (kommaseparert)",
|
||||
"extended_entity_discovery": "Inkluder ekstra sensorer, brytere og lys",
|
||||
"hass_url": "URL-adresse for lokalt nettverk for å få tilgang til Home Assistant",
|
||||
"include_devices": "Inkluder bare disse enhetene (kommaseparert)",
|
||||
"otp_secret": "52-tegns nøkkel fra autentiseringsappen for Amazon 2SV",
|
||||
"password": "Passord",
|
||||
"public_url": "Offentlig URL delt med eksterne vertsbaserte tjenester",
|
||||
"queue_delay": "Forsinkelse for å sette flere kommandoer sammen i kø (sekunder)",
|
||||
"scan_interval": "Planlagt avstemningsintervall (sekunder)",
|
||||
"securitycode": "Engangspassord (OTP)",
|
||||
"should_get_network": "Oppdag Alexa-nettverket",
|
||||
"url": "Amazon-regiondomenet (f.eks. Amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Muliggjør svært detaljert logging på spornivå for avansert feilsøking. \n Anbefales ikke for normal drift på grunn av økt loggvolum. \n Sørg for at loggnivåene er satt til DEBUG for full utdata.",
|
||||
"otp_secret": "Eksempel: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Luftkvalitet"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Karbonmonoksid"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Fuktighet"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Innendørs luftkvalitet"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Partikkelformet materiale"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Flyktige organiske forbindelser"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Neste alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Neste påminnelse"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Neste timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperatur"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Ikke forstyrr"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Gjenta"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Bland"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "YAML-konfigurasjonen av Alexa Media Player er utdatert.\nFjern `alexa_media` fra konfigurasjonen din, start Home Assistant på nytt og bruk brukergrensesnittet til å konfigurere den i stedet.\nInnstillinger > Enheter og tjenester > Integrasjoner > LEGG TIL INTEGRASJON",
|
||||
"title": "YAML-konfigurasjonen er utdatert"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Avansert feilsøking",
|
||||
"exclude_devices": "eller Ekskluder disse enhetene fra alle (kommaseparert)",
|
||||
"extended_entity_discovery": "Inkluder ekstra sensorer, brytere og lys",
|
||||
"include_devices": "Inkluder bare disse enhetene (kommaseparert)",
|
||||
"otp_secret": "52-tegns nøkkel fra autentiseringsappen for Amazon 2SV",
|
||||
"public_url": "Offentlig URL delt med eksterne vertsbaserte tjenester",
|
||||
"queue_delay": "Forsinkelse for å sette flere kommandoer sammen i kø (sekunder)",
|
||||
"scan_interval": "Planlagt avstemningsfrekvens (sekunder)",
|
||||
"should_get_network": "Oppdag Alexa-nettverket"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Muliggjør svært detaljert logging på spornivå for avansert feilsøking. \n Anbefales ikke for normal drift på grunn av økt loggvolum. \n Sørg for at loggnivåene er satt til DEBUG for full utdata.",
|
||||
"otp_secret": "Eksempel: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Obligatoriske oppføringer",
|
||||
"title": "Alexa Media Player – Rekonfigurasjon"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Aktiverer Alexa-nettverksoppdagelse på nytt, slik at neste avstemningssyklus vil oppdage Alexa-enheter på nytt for de valgte kontoene.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Valgfri e-postadresse for Alexa-kontoen eller liste over e-postadresser. Hvis tom, vil alle kjente kontoer bli oppdatert.",
|
||||
"name": "E-postadresse"
|
||||
}
|
||||
},
|
||||
"name": "Aktiver nettverksoppdagelse"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Tving kontoen til å logge ut. Brukes hovedsakelig til feilsøking.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Kontoer som skal tømmes. Tøm vil tømme alle.",
|
||||
"name": "E-postadresse"
|
||||
}
|
||||
},
|
||||
"name": "Tving utlogging"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analyserer historikkpostene for den angitte enheten",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entitet å hente historien for",
|
||||
"name": "Velg mediespiller:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Antall oppføringer å få",
|
||||
"name": "Antall oppføringer"
|
||||
}
|
||||
},
|
||||
"name": "Få historikk"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Gjenopprett forrige volumnivå på Alexa mediespillerenhet",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entitet for å gjenopprette forrige volumnivå på",
|
||||
"name": "Velg mediespiller:"
|
||||
}
|
||||
},
|
||||
"name": "Gjenopprett forrige volum"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Tvinger frem oppdatering av sist oppringte echo-enhet for hver Alexa-konto.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Liste over Alexa-kontoer som skal oppdateres. Hvis tom, oppdateres alle kjente kontoer.",
|
||||
"name": "E-postadresse"
|
||||
}
|
||||
},
|
||||
"name": "Oppdater sist oppringte sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "De pagina 'Wachtwoord vergeten' is gedetecteerd. Dit is meestal het gevolg van te veel mislukte inlogpogingen. Amazon kan actie vereisen voordat opnieuw kan worden ingelogd.",
|
||||
"login_failed": "Het inloggen van Alexa Mediaspeler is mislukt.",
|
||||
"reauth_successful": "Alexa Mediaspeler is met succes opnieuw geverifieerd. Negeer a.u.b. het bericht \"Afgebroken\" van HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} is ongeldig",
|
||||
"connection_error": "Fout bij verbinding; controleer netwerk en probeer opnieuw",
|
||||
"identifier_exists": "E-mailadres voor Alexa-URL is al geregistreerd",
|
||||
"invalid_auth": "Inloggen is mislukt. Controleer uw e-mailadres, wachtwoord en Authenticator-sleutel nogmaals.",
|
||||
"invalid_credentials": "Ongeldige inloggegevens",
|
||||
"invalid_url": "De URL is ongeldig: {message}",
|
||||
"oauth_error": "OAuth-aanmelding kon niet worden voltooid. Probeer het opnieuw.",
|
||||
"unable_to_connect_hass_url": "Kan geen verbinding maken met de lokale Home Assistant-URL. Controleer de URL onder Instellingen > Systeem > Netwerk > Home Assistant-URL > Lokaal netwerk.",
|
||||
"unknown_error": "Onbekende fout: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Negeren en doorgaan - Ik begrijp dat er geen ondersteuning wordt geboden bij inlogproblemen wanneer ik deze waarschuwing omzeil."
|
||||
},
|
||||
"description": "De HA server kan geen verbinding maken met de opgegeven URL: {hass_url}.\n> {error}\n\nOm dit op te lossen, controleer of uw browser {hass_url} kan bereiken. Dit veld vindt u in Instellingen > Systeem > Netwerk > Home Assistant-URL.\n\nAls u er **zeker van bent** dat uw browser deze URL kan bereiken, kunt u deze waarschuwing negeren.",
|
||||
"title": "Alexa Mediaspeler - Kan geen verbinding maken met HA URL"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Ja, de OTP-code is geverifieerd."
|
||||
},
|
||||
"description": "**{email} - alexa.{url}**\nHeb je met succes een OTP van de ingebouwde 2FA App Key met Amazon bevestigd? \n >OTP-code {message}",
|
||||
"title": "Alexa Mediaspeler - OTP Bevestiging"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Geavanceerde foutopsporing",
|
||||
"email": "E-mailadres",
|
||||
"exclude_devices": "of Sluit deze apparaten uit van alles (gescheiden door komma's)",
|
||||
"extended_entity_discovery": "Voeg extra sensoren, schakelaars en lampen toe.",
|
||||
"hass_url": "Lokale netwerk-URL om toegang te krijgen tot Home Assistant",
|
||||
"include_devices": "Vermeld alleen deze apparaten (gescheiden door komma's)",
|
||||
"otp_secret": "52-karakter Authenticator-appsleutel voor Amazon 2SV",
|
||||
"password": "Wachtwoord",
|
||||
"public_url": "Openbare URL gedeeld met externe hostingdiensten",
|
||||
"queue_delay": "Vertraging om meerdere opdrachten tegelijk in de wachtrij te plaatsen (seconden)",
|
||||
"scan_interval": "Gepland pollinginterval (seconden)",
|
||||
"securitycode": "Eenmalig wachtwoord (OTP)",
|
||||
"should_get_network": "Ontdek het Alexa-netwerk",
|
||||
"url": "Domeinnaam van Amazon regio (bijv: amazon.nl)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Schakelt zeer gedetailleerde logboekregistratie op traceniveau in voor geavanceerde probleemoplossing. \n Niet aanbevolen voor normaal gebruik vanwege het toegenomen logvolume. \n Zorg ervoor dat de logniveaus zijn ingesteld op DEBUG voor volledige uitvoer.",
|
||||
"otp_secret": "Voorbeeld: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Luchtkwaliteit"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Koolmonoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Vochtigheid"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Binnenluchtkwaliteit"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Fijnstof"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Vluchtige organische verbindingen"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Volgende alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Volgende herinnering"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Volgende keer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperatuur"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Niet storen"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Herhalen"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Schudden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "De YAML-configuratie van Alexa Media Player is verouderd.\n\nVerwijder `alexa_media` uit uw configuratie, herstart Home Assistant en gebruik in plaats daarvan de gebruikersinterface om het te configureren.\n\nInstellingen > Apparaten en services > Integraties > INTEGRATIE TOEVOEGEN",
|
||||
"title": "YAML-configuratie is verouderd"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Geavanceerde foutopsporing",
|
||||
"exclude_devices": "of Sluit deze apparaten uit van alles (gescheiden door komma's)",
|
||||
"extended_entity_discovery": "Voeg extra sensoren, schakelaars en lampen toe",
|
||||
"include_devices": "Vermeld alleen deze apparaten (gescheiden door komma's)",
|
||||
"otp_secret": "52-karakter Authenticator-appsleutel voor Amazon 2SV",
|
||||
"public_url": "Openbare URL gedeeld met externe hostingdiensten",
|
||||
"queue_delay": "Vertraging om meerdere opdrachten tegelijk in de wachtrij te plaatsen (seconden)",
|
||||
"scan_interval": "Geplande pollingfrequentie (seconden)",
|
||||
"should_get_network": "Ontdek het Alexa-netwerk"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Schakelt zeer gedetailleerde logboekregistratie op traceniveau in voor geavanceerde probleemoplossing. \n Niet aanbevolen voor normaal gebruik vanwege het toegenomen logvolume. \n Zorg ervoor dat de logniveaus zijn ingesteld op DEBUG voor volledige uitvoer.",
|
||||
"otp_secret": "Voorbeeld: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Vereiste invoer",
|
||||
"title": "Alexa Mediaspeler - Herconfiguratie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Hiermee wordt de Alexa-netwerkdetectie opnieuw ingeschakeld, zodat de volgende pollingcyclus Alexa-apparaten voor de geselecteerde accounts opnieuw kan detecteren.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Optioneel Alexa-account-e-mailadres of lijst met e-mailadressen. Indien leeg, worden alle bekende accounts vernieuwd.",
|
||||
"name": "E-mailadres"
|
||||
}
|
||||
},
|
||||
"name": "Schakel netwerkdetectie in"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Forceer account om uit te loggen. Voornamelijk gebruikt voor foutopsporing.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Te vereffenen accounts. Leegmaken zal alles wissen.",
|
||||
"name": "E-mailadres"
|
||||
}
|
||||
},
|
||||
"name": "Uitloggen forceren"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analyseert de geschiedenisrecords voor het opgegeven apparaat",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entiteit om de geschiedenis op te halen",
|
||||
"name": "Selecteer mediaspeler:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Aantal inzendingen om te krijgen",
|
||||
"name": "Aantal inzendingen"
|
||||
}
|
||||
},
|
||||
"name": "Geschiedenisrecords ophalen"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Herstel het vorige volumeniveau op het Alexa-mediaspelerapparaat",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entiteit om het vorige volumeniveau te herstellen op",
|
||||
"name": "Selecteer mediaspeler:"
|
||||
}
|
||||
},
|
||||
"name": "Vorig volume herstellen"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Forceert update van last_called echo apparaat voor elk Alexa-account.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Lijst met Alexa accounts om bij te werken. Als het veld leeg is, worden alle bekende accounts bijgewerkt.",
|
||||
"name": "E-mailadres"
|
||||
}
|
||||
},
|
||||
"name": "Laatst opgeroepen sensor bijwerken"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "Wykryto stronę „Zapomniałem hasła”. Zwykle jest to spowodowane zbyt wieloma nieudanymi próbami logowania. Amazon może wymagać podjęcia działań, zanim będzie można ponownie spróbować zalogować się.",
|
||||
"login_failed": "Alexa Media Player nie może się zalogować.",
|
||||
"reauth_successful": "Odtwarzacz multimedialny Alexa pomyślnie przeszedł ponowne uwierzytelnienie. Proszę zignorować komunikat „Przerwano” od HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} jest nieprawidłowy",
|
||||
"connection_error": "Błąd podczas łączenia; sprawdź sieć i spróbuj ponownie",
|
||||
"identifier_exists": "Adres e-mail dla Alexy już jest zarejestrowany",
|
||||
"invalid_auth": "Logowanie nie powiodło się. Sprawdź ponownie swój adres e-mail, hasło i klucz uwierzytelniający.",
|
||||
"invalid_credentials": "Nieprawidłowe dane logowania",
|
||||
"invalid_url": "URL jest nieprawidłowy: {message}",
|
||||
"oauth_error": "Nie udało się dokończyć logowania OAuth. Spróbuj ponownie.",
|
||||
"unable_to_connect_hass_url": "Nie można połączyć się z lokalnym adresem URL Home Assistant. Sprawdź adres URL w Ustawieniach > System > Sieć > Adres URL Home Assistant > Sieć lokalna.",
|
||||
"unknown_error": "Nieznany błąd: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignoruj i kontynuuj – rozumiem, że nie jest zapewniane wsparcie dla problemów z logowaniem wynikających z obejścia tego ostrzeżenia."
|
||||
},
|
||||
"description": "Serwer HA nie może połączyć się z podanym adresem URL: {hass_url}.\n> {error}\n\nAby rozwiązać ten problem, upewnij się, że Twoja przeglądarka może uzyskać dostęp do adresu {hass_url}. To pole znajduje się w Ustawieniach > System > Sieć > Adres URL Asystenta Domowego.\n\nJeśli masz **pewność**, że Twoja przeglądarka może uzyskać dostęp do tego adresu URL, możesz pominąć to ostrzeżenie.",
|
||||
"title": "Alexa Media Player – nie można połączyć się z adresem URL HA"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Tak, kod OTP został zweryfikowany"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \nCzy zweryfikowałeś kod OTP w Amazon 2SV?\n>Kod OTP: {message}",
|
||||
"title": "Alexa Media Player - Potwierdzanie hasła jednorazowego"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Zaawansowane debugowanie",
|
||||
"email": "Adres e-mail",
|
||||
"exclude_devices": "lub Wyklucz te urządzenia ze wszystkich (rozdzielone przecinkami)",
|
||||
"extended_entity_discovery": "Dodaj dodatkowe czujniki, przełączniki i światła",
|
||||
"hass_url": "Lokalny adres URL sieciowy umożliwiający dostęp do Home Assistant",
|
||||
"include_devices": "Uwzględnij tylko te urządzenia (rozdzielone przecinkami)",
|
||||
"otp_secret": "52-znakowy klucz aplikacji uwierzytelniającej dla Amazon 2SV",
|
||||
"password": "Hasło",
|
||||
"public_url": "Publiczny adres URL udostępniany zewnętrznym usługom hostowanym",
|
||||
"queue_delay": "Opóźnienie w kolejkowaniu wielu poleceń (sekundy)",
|
||||
"scan_interval": "Zaplanowany interwał sondowania (sekundy)",
|
||||
"securitycode": "Jednorazowe hasło (OTP)",
|
||||
"should_get_network": "Odkryj sieć Alexa",
|
||||
"url": "Region/domena Amazon (np. amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Włącza bardzo szczegółowe rejestrowanie na poziomie śledzenia w celu zaawansowanego rozwiązywania problemów. \n Niezalecane do normalnego użytkowania ze względu na zwiększoną objętość dziennika. \n Upewnij się, że poziomy rejestratora są ustawione na DEBUG, aby uzyskać pełny wynik.",
|
||||
"otp_secret": "Przykład: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Jakość powietrza"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Tlenek węgla"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Wilgotność"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Jakość powietrza w pomieszczeniach"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Cząstki stałe"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Lotne związki organiczne"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Następny alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Następne przypomnienie"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Następnym razem"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperatura"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Nie przeszkadzać"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Powtarzać"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Odtwarzanie losowe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "Konfiguracja YAML odtwarzacza multimedialnego Alexa jest przestarzała.\nUsuń „alexa_media” z konfiguracji, uruchom ponownie Asystenta Domowego i skonfiguruj go za pomocą interfejsu użytkownika.\nUstawienia > Urządzenia i usługi > Integracje > DODAJ INTEGRACJĘ",
|
||||
"title": "Konfiguracja YAML jest przestarzała"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Zaawansowane debugowanie",
|
||||
"exclude_devices": "lub Wyklucz te urządzenia ze wszystkich (rozdzielone przecinkami)",
|
||||
"extended_entity_discovery": "Dodaj dodatkowe czujniki, przełączniki i światła",
|
||||
"include_devices": "Uwzględnij tylko te urządzenia (rozdzielone przecinkami)",
|
||||
"otp_secret": "52-znakowy klucz aplikacji uwierzytelniającej dla Amazon 2SV",
|
||||
"public_url": "Publiczny adres URL udostępniany zewnętrznym usługom hostowanym",
|
||||
"queue_delay": "Opóźnienie w kolejkowaniu wielu poleceń (sekundy)",
|
||||
"scan_interval": "Interwał skanowania (sekundy)",
|
||||
"should_get_network": "Odkryj sieć Alexa"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Włącza bardzo szczegółowe rejestrowanie na poziomie śledzenia w celu zaawansowanego rozwiązywania problemów. \n Niezalecane do normalnego użytkowania ze względu na zwiększoną objętość dziennika. \n Upewnij się, że poziomy rejestratora są ustawione na DEBUG, aby uzyskać pełny wynik.",
|
||||
"otp_secret": "Przykład: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Wymagane wpisy",
|
||||
"title": "Odtwarzacz multimedialny Alexa – rekonfiguracja"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Ponownie włącza wykrywanie sieci Alexa, dzięki czemu kolejny cykl sondowania ponownie wykryje urządzenia Alexa dla wybranych kont.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Opcjonalny adres e-mail konta Alexa lub lista adresów e-mail. Jeśli jest pusty, wszystkie znane konta zostaną odświeżone.",
|
||||
"name": "Adres e-mail"
|
||||
}
|
||||
},
|
||||
"name": "Włącz wykrywanie sieci"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Wymuś wylogowanie z konta. Używane głównie do debugowania.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Konta do wyczyszczenia. Opcja „Opróżnij” wyczyści wszystkie konta.",
|
||||
"name": "Adres e-mail"
|
||||
}
|
||||
},
|
||||
"name": "Wymuś wylogowanie"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analizuje zapisy historii dla określonego urządzenia",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Podmiot, dla którego ma zostać pobrana historia",
|
||||
"name": "Wybierz odtwarzacz multimedialny:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Liczba wpisów do uzyskania",
|
||||
"name": "Liczba wpisów"
|
||||
}
|
||||
},
|
||||
"name": "Pobierz zapisy historyczne"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Przywróć poprzedni poziom głośności na urządzeniu z odtwarzaczem multimedialnym Alexa",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Podmiot przywracający poprzedni poziom głośności",
|
||||
"name": "Wybierz odtwarzacz multimedialny:"
|
||||
}
|
||||
},
|
||||
"name": "Przywróć poprzednią głośność"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Wymusza aktualizację ostatnio używanego urządzenia echo dla każdego konta Alexa.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Lista kont Alexa do aktualizacji. Jeśli pusta, zaktualizuje wszystkie znane konta.",
|
||||
"name": "Adres e-mail"
|
||||
}
|
||||
},
|
||||
"name": "Aktualizuj ostatnio wywołany czujnik"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "A página de Esqueceu a Senha foi detectada. Isso normalmente é resultado de muitas tentativas de login falhadas. A Amazon pode exigir ação antes que seja possível tentar fazer login novamente.",
|
||||
"login_failed": "Alexa Media Player falhou no login.",
|
||||
"reauth_successful": "Alexa Media Player reautenticado com sucesso. Por favor, ignore a mensagem \"Abortado\" do Home Assistant."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} é inválido",
|
||||
"connection_error": "Erro de conexão; verifique a sua conexão e tente novamente",
|
||||
"identifier_exists": "Email para URL Alexa já registrado",
|
||||
"invalid_auth": "O ‘login’ não foi bem-sucedido. Verifique novamente seu endereço eletrônico, senha e chave de autenticação.",
|
||||
"invalid_credentials": "Credenciais inválidas",
|
||||
"invalid_url": "O URL é inválido: {message}",
|
||||
"oauth_error": "Não foi possível concluir o ‘login’ OAuth. Tente novamente.",
|
||||
"unable_to_connect_hass_url": "Não foi possível conectar ao URL local do Home Assistant. Verifique o URL em Configurações > Sistema > Rede > URL do Home Assistant > Rede local.",
|
||||
"unknown_error": "Erro desconhecido: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignorar e continuar - Entendo que nenhum suporte para problemas de login é fornecido para ignorar este aviso."
|
||||
},
|
||||
"description": "O servidor HA não consegue se conectar ao URL fornecido: {hass_url}.\n> {error}\n\nPara corrigir isso, confirme se o seu navegador consegue acessar {hass_url}. Este campo está em Configurações > Sistema > Rede > URL do Home Assistant.\n\nSe você tiver **certeza** de que seu navegador consegue acessar este URL, pode ignorar este aviso.",
|
||||
"title": "Alexa Media Player - Não foi possível se conectar a URL do Home Assistant"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Sim, o código OTP foi verificado."
|
||||
},
|
||||
"description": "**{email} - alexa.{url}**\nVocê verificou o código OTP na verificação em duas etapas da Amazon?\n >Código OTP: {message}",
|
||||
"title": "Alexa Media Player - Confirmação OTP"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Depuração avançada",
|
||||
"email": "Endereço eletrônico",
|
||||
"exclude_devices": "ou Excluir esses dispositivos de todos (separados por vírgula)",
|
||||
"extended_entity_discovery": "Inclua sensores, interruptores e luzes adicionais.",
|
||||
"hass_url": "URL da rede local para acessar o Home Assistant",
|
||||
"include_devices": "Inclua apenas estes dispositivos (separados por vírgula)",
|
||||
"otp_secret": "Chave de 52 caracteres do App Autenticador para 2SV da Amazon",
|
||||
"password": "Senha",
|
||||
"public_url": "URL pública compartilhada com serviços hospedados externamente",
|
||||
"queue_delay": "Tempo de espera para enfileirar vários comandos (em segundos)",
|
||||
"scan_interval": "Intervalo de sondagem programado (segundos)",
|
||||
"securitycode": "Senha de uso único (OTP)",
|
||||
"should_get_network": "Descubra a rede Alexa",
|
||||
"url": "Domínio regional da Amazon (ex: amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Habilita o registro detalhado em nível de rastreamento para solução de problemas avançada. \n Não recomendado para operação normal devido ao aumento do volume de logs. \n Certifique-se de que os níveis de registro estejam definidos como DEBUG para obter a saída completa.",
|
||||
"otp_secret": "Exemplo: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Qualidade do ar"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Monóxido de carbono"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Umidade"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Qualidade do ar interior"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Material particulado"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Compostos orgânicos voláteis"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Próximo alarme"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Próximo lembrete"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Próximo cronômetro"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperatura"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Não incomodar"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repita"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Embaralhar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "A configuração YAML do Alexa Media Player está obsoleta.\nRemova `alexa_media` da sua configuração, reinicie o Home Assistant e use a ‘interface’ do usuário para configurá-lo.\n\nConfigurações > Dispositivos e serviços > Integrações > ADICIONAR INTEGRAÇÃO",
|
||||
"title": "A configuração YAML está obsoleta!"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Depuração avançada",
|
||||
"exclude_devices": "ou Excluir esses dispositivos de todos (separados por vírgula)",
|
||||
"extended_entity_discovery": "Inclua sensores, interruptores e luzes adicionais.",
|
||||
"include_devices": "Inclua apenas estes dispositivos (separados por vírgula)",
|
||||
"otp_secret": "Chave de 52 caracteres do App Autenticador para 2SV da Amazon",
|
||||
"public_url": "URL pública compartilhada com serviços hospedados externamente",
|
||||
"queue_delay": "Tempo de espera para enfileirar vários comandos (em segundos)",
|
||||
"scan_interval": "Frequência de sondagem programada (segundos)",
|
||||
"should_get_network": "Descubra a rede Alexa"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Habilita o registro detalhado em nível de rastreamento para solução de problemas avançada. \n Não recomendado para operação normal devido ao aumento do volume de logs. \n Certifique-se de que os níveis de registro estejam definidos como DEBUG para obter a saída completa.",
|
||||
"otp_secret": "Exemplo: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Entradas obrigatórias",
|
||||
"title": "Alexa Media Player - Reconfiguração"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Reativa a descoberta de rede da Alexa para que o próximo ciclo de pesquisa redescubra os dispositivos Alexa das contas selecionadas.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Opcional: Endereço eletrônico da conta Alexa ou lista de endereços eletrônicos. Se estiver vazio, todas as contas conhecidas serão atualizadas.",
|
||||
"name": "Endereço eletrônico"
|
||||
}
|
||||
},
|
||||
"name": "Habilitar descoberta de rede"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Forçar o logout da conta. Usado principalmente para depuração.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Contas para limpar. Deixar vazio limpará tudo.",
|
||||
"name": "Endereço eletrônico"
|
||||
}
|
||||
},
|
||||
"name": "Forçar o logout"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analisa os registros de histórico do dispositivo especificado:",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entidade para obter o histórico de:",
|
||||
"name": "Selecione o media player:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Número de entradas para obter:",
|
||||
"name": "Número de entradas"
|
||||
}
|
||||
},
|
||||
"name": "Obter registros históricos"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Restaurar o nível de volume anterior no dispositivo reprodutor de mídia Alexa.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entidade para restaurar o nível de volume anterior",
|
||||
"name": "Selecione o media player:"
|
||||
}
|
||||
},
|
||||
"name": "Restaurar volume anterior"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Força a atualização do último dispositivo eco chamado para cada conta Alexa.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Lista de contas Alexa para atualizar. Se deixar vazio, atualizará todas as contas conhecidas.",
|
||||
"name": "Endereço eletrônico"
|
||||
}
|
||||
},
|
||||
"name": "Atualizar último sensor chamado"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "A página de Esqueci a Palavra-passe foi detetada. Isto normalmente é o resultado de demasiadas tentativas de login falhadas. A Amazon pode exigir uma ação antes de ser possível tentar iniciar sessão novamente.",
|
||||
"login_failed": "Alexa Media Player não conseguiu fazer o login.",
|
||||
"reauth_successful": "Alexa Media Player reautenticado com sucesso. Por favor, ignore a mensagem \"Aborted\" do HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} é inválido",
|
||||
"connection_error": "Erro ao conectar; verifique a rede e tente novamente",
|
||||
"identifier_exists": "E-mail para URL Alexa já registado",
|
||||
"invalid_auth": "O ‘login’ não foi bem-sucedido. Verifique novamente o seu endereço eletrónico, senha e chave de autenticação.",
|
||||
"invalid_credentials": "Credenciais inválidas",
|
||||
"invalid_url": "O URL é inválido: {message}",
|
||||
"oauth_error": "Não foi possível concluir o login OAuth. Tente novamente.",
|
||||
"unable_to_connect_hass_url": "Não foi possível conectar ao URL local do Home Assistant. Verifique o URL em Configurações > Sistema > Rede > URL do Home Assistant > Rede local.",
|
||||
"unknown_error": "Erro desconhecido: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignore e Continue - Entendo que não há suporte para problemas de login para ignorar este aviso."
|
||||
},
|
||||
"description": "O servidor HA não consegue se conectar ao URL fornecido: {hass_url}.\n > {error} \n\nPara corrigir isso, confirme se o seu navegador consegue acessar o endereço. {hass_url}. Este campo é de Configurações > Sistema > Rede > URL do Home Assistant.\n\nSe você tiver **certeza** de que o seu navegador consegue acessar este URL, pode ignorar este aviso.",
|
||||
"title": "Alexa Media Player - Não é possível conectar ao URL de alta disponibilidade"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Sim, o código OTP foi verificado."
|
||||
},
|
||||
"description": "** {email} - alexa. {url} **\nVocê verificou o código OTP na verificação de duas vias da Amazon?\n>Código OTP: {message}",
|
||||
"title": "Alexa Media Player - Confirmação OTP"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Depuração avançada",
|
||||
"email": "Endereço de e-mail",
|
||||
"exclude_devices": "ou Excluir esses dispositivos de todos (separados por vírgula)",
|
||||
"extended_entity_discovery": "Inclua sensores, interruptores e luzes adicionais.",
|
||||
"hass_url": "URL da rede local para acessar o Home Assistant",
|
||||
"include_devices": "Inclua apenas estes dispositivos (separados por vírgula)",
|
||||
"otp_secret": "Chave de 52 caracteres da App Autenticadora para Amazon 2SV",
|
||||
"password": "Senha",
|
||||
"public_url": "URL pública compartilhada com serviços hospedados externos",
|
||||
"queue_delay": "Tempo de espera para enfileirar vários comandos (em segundos)",
|
||||
"scan_interval": "Intervalo de sondagem programado (segundos)",
|
||||
"securitycode": "Palavra-passe de uso único (OTP)",
|
||||
"should_get_network": "Descubra a rede Alexa",
|
||||
"url": "Região do domínio Amazon (ex. amazon.com.br)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Habilita o registro detalhado em nível de rastreamento para solução de problemas avançada. \n Não recomendado para operação normal devido ao aumento do volume de logs. \n Certifique-se de que os níveis de registro estejam definidos como DEBUG para obter a saída completa.",
|
||||
"otp_secret": "Exemplo: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Qualidade do ar"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Monóxido de carbono"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Umidade"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Qualidade do ar interior"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Material particulado"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Compostos orgânicos voláteis"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Próximo alarme"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Próximo lembrete"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Próximo cronômetro"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperatura"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Não incomodar"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repita"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Embaralhar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "A configuração YAML do Alexa Media Player está obsoleta.\nRemova alexa_media da sua configuração, reinicie o Home Assistant e utilize a “interface” do utilizador para o configurar.\nConfigurações > Dispositivos e serviços > Integrações > ADICIONAR INTEGRAÇÃO",
|
||||
"title": "A configuração YAML está obsoleta"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Depuração avançada",
|
||||
"exclude_devices": "ou Excluir esses dispositivos de todos (separados por vírgula)",
|
||||
"extended_entity_discovery": "Inclua sensores, interruptores e luzes adicionais.",
|
||||
"include_devices": "Inclua apenas estes dispositivos (separados por vírgula)",
|
||||
"otp_secret": "Chave de 52 caracteres da App Autenticadora para Amazon 2SV",
|
||||
"public_url": "URL pública compartilhada com serviços hospedados externamente",
|
||||
"queue_delay": "Tempo de espera para enfileirar vários comandos (em segundos)",
|
||||
"scan_interval": "Frequência de sondagem programada (segundos)",
|
||||
"should_get_network": "Descubra a rede Alexa"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Habilita o registro detalhado em nível de rastreamento para solução de problemas avançada. \n Não recomendado para operação normal devido ao aumento do volume de logs. \n Certifique-se de que os níveis de registro estejam definidos como DEBUG para obter a saída completa.",
|
||||
"otp_secret": "Exemplo: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Entradas obrigatórias",
|
||||
"title": "Alexa Media Player - Reconfiguração"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Reativa a descoberta de rede da Alexa para que o próximo ciclo de pesquisa redescubra os dispositivos Alexa das contas selecionadas.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Opcional: Endereço eletrónico da conta Alexa ou lista de endereços eletrónicos. Se estiver vazio, todas as contas conhecidas serão atualizadas.",
|
||||
"name": "Endereço de email"
|
||||
}
|
||||
},
|
||||
"name": "Habilitar descoberta de rede"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Forçar o logout da conta. Usado principalmente para depuração.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Contas a limpar. Vazio vai limpar tudo.",
|
||||
"name": "Endereço de email"
|
||||
}
|
||||
},
|
||||
"name": "Forçar logout"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Analisa os registos de histórico do dispositivo especificado",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entidade para obter o histórico de",
|
||||
"name": "Selecione o media player:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Número de entradas para obter",
|
||||
"name": "Número de entradas"
|
||||
}
|
||||
},
|
||||
"name": "Obter registos históricos"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Restaurar o nível de volume anterior no dispositivo reprodutor de média Alexa",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entidade para restaurar o nível de volume anterior em",
|
||||
"name": "Selecione o media player:"
|
||||
}
|
||||
},
|
||||
"name": "Restaurar volume anterior"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Força a atualização do dispositivo de echo last_called para cada conta Alexa.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Lista de contas Alexa para atualizar. Se estiver vazio, atualizará todas as contas conhecidas.",
|
||||
"name": "Endereço de email"
|
||||
}
|
||||
},
|
||||
"name": "Atualizar último sensor chamado"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "Обнаружена страница «Забыли пароль». Обычно это происходит из-за слишком большого количества неудачных попыток входа. Amazon может потребовать действий, прежде чем можно будет повторно войти в систему.",
|
||||
"login_failed": "Алекса Медиа Проигрыватель логин не удался.",
|
||||
"reauth_successful": "Алекса Медиа Проигрыватель успешно прошел повторную аутентификацию. Пожалуйста, игнорируйте сообщение «Прервано» от HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} недействителен",
|
||||
"connection_error": "Ошибка подключения; проверьте сеть и повторите попытку",
|
||||
"identifier_exists": "Электронная почта для Alexa уже зарегистрирована",
|
||||
"invalid_auth": "Не удалось войти. Пожалуйста, проверьте адрес электронной почты, пароль и ключ аутентификации.",
|
||||
"invalid_credentials": "Неверные учетные данные",
|
||||
"invalid_url": "Недопустимый URL-адрес: {message}",
|
||||
"oauth_error": "Не удалось завершить вход через OAuth. Попробуйте ещё раз.",
|
||||
"unable_to_connect_hass_url": "Не удаётся подключиться к локальному URL-адресу Home Assistant. Проверьте URL-адрес в разделе «Настройки» > «Система» > «Сеть» > «URL-адрес Home Assistant» > «Локальная сеть».",
|
||||
"unknown_error": "Неизвестная ошибка: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Игнорировать и продолжить. Я понимаю, что для обхода этого предупреждения не предоставляется никакой поддержки при проблемах со входом в систему."
|
||||
},
|
||||
"description": "Home Assistant сервер не может подключиться по указанному адресу: {hass_url}.\n> {error}\n\nДля решения этой проблемы, пожалуйста, убедитесь, что ваш браузер имеет доступ к указанному ресурсу. {hass_url}. Это поле находится в разделе Настройки > Система > Сеть > URL-адрес Home Assistant.\n\nЕсли вы **уверены**, что ваш клиент может получить доступ к этому URL-адресу, вы можете обойти это предупреждение.",
|
||||
"title": "Алекса Медиа Проигрыватель - не может подключиться к Home Assistant адресу"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Да, код OTP был подтвержден."
|
||||
},
|
||||
"description": "**{email} - Алекса.{url}** \nВы подтвердили OTP-код в Amazon 2SV?\n >OTP-код {message}",
|
||||
"title": "Алекса Медиа Проигрыватель — подтверждение OTP"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Расширенные возможности отладки",
|
||||
"email": "Адрес электронной почты",
|
||||
"exclude_devices": "или Исключить эти устройства из всех (разделенных запятыми)",
|
||||
"extended_entity_discovery": "Включите дополнительные датчики, выключатели и осветительные приборы.",
|
||||
"hass_url": "URL-адрес локальной сети для доступа к Home Assistant",
|
||||
"include_devices": "Укажите только эти устройства (разделенные запятыми).",
|
||||
"otp_secret": "52-символьный ключ приложения аутентификатора для Amazon 2SV",
|
||||
"password": "Пароль",
|
||||
"public_url": "Публичный URL-адрес, предоставленный внешним размещенным службам",
|
||||
"queue_delay": "Задержка для объединения нескольких команд в очередь (в секундах)",
|
||||
"scan_interval": "Запланированный интервал опроса (в секундах)",
|
||||
"securitycode": "Одноразовый пароль (OTP)",
|
||||
"should_get_network": "Откройте для себя сеть Alexa",
|
||||
"url": "Домен региона Amazon (например, amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Включает очень подробное логирование на уровне трассировки для расширенного поиска и устранения неисправностей. \n Не рекомендуется для обычной работы из-за увеличенного объема логов. \n Убедитесь, что уровни логирования установлены на DEBUG для получения полного вывода.",
|
||||
"otp_secret": "Пример: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Качество воздуха"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Оксид углерода"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Влажность"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Качество воздуха в помещении"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Твердые частицы"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Летучие органические соединения"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Следующий будильник"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Следующее напоминание"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "В следующий раз"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Температура"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Просьба не беспокоить"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Повторить"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Перетасовка"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "Конфигурация Alexa Media Player в формате YAML устарела.\nУдалите `alexa_media` из конфигурации, перезапустите Home Assistant и используйте пользовательский интерфейс для настройки.\nНастройки > Устройства и сервисы > Интеграции > ДОБАВИТЬ ИНТЕГРАЦИЮ",
|
||||
"title": "Конфигурация YAML устарела"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Расширенные возможности отладки",
|
||||
"exclude_devices": "или Исключить эти устройства из всех (разделенных запятыми)",
|
||||
"extended_entity_discovery": "Включите дополнительные датчики, выключатели и осветительные приборы.",
|
||||
"include_devices": "Укажите только эти устройства (разделенные запятыми).",
|
||||
"otp_secret": "52-символьный ключ приложения аутентификатора для Amazon 2SV",
|
||||
"public_url": "Публичный URL-адрес предоставляется внешним хостинг-сервисам.",
|
||||
"queue_delay": "Задержка для объединения нескольких команд в очередь (в секундах)",
|
||||
"scan_interval": "Запланированная частота опроса (в секундах)",
|
||||
"should_get_network": "Откройте для себя сеть Alexa"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Включает очень подробное логирование на уровне трассировки для расширенного поиска и устранения неисправностей. \n Не рекомендуется для обычной работы из-за увеличенного объема логов. \n Убедитесь, что уровни логирования установлены на DEBUG для получения полного вывода.",
|
||||
"otp_secret": "Пример: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Обязательные поля",
|
||||
"title": "Alexa Media Player - Перенастройка"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Повторно включает обнаружение сети Alexa, чтобы в следующем цикле опроса были повторно обнаружены устройства Alexa для выбранных учетных записей.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Необязательный адрес электронной почты для учётной записи Alexa или список адресов электронной почты. Если не указано, все известные учётные записи будут обновлены.",
|
||||
"name": "Почтовые адреса"
|
||||
}
|
||||
},
|
||||
"name": "Включить сетевое обнаружение"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Принудительный выход из аккаунта. В основном используется для отладки.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Аккаунты для очистки. Если пустое, то будут очищены все.",
|
||||
"name": "Почтовые адреса"
|
||||
}
|
||||
},
|
||||
"name": "Принудительный выход"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Анализирует записи истории для указанного устройства.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Сущность, для которой нужно получить историю",
|
||||
"name": "Выберите медиа плеер:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Количество записей, которые нужно получить",
|
||||
"name": "Количество записей"
|
||||
}
|
||||
},
|
||||
"name": "Получить исторические записи"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Восстановить предыдущий уровень громкости на медиа плеере Alexa",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Сущность для восстановления предыдущего уровня громкости",
|
||||
"name": "Выберите медиа плеер:"
|
||||
}
|
||||
},
|
||||
"name": "Восстановить предыдущий том"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Принудительное обновление последнего вызванного устройства для каждого аккаунта Алекса.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Список аккаунтов Алекса для обновления. Если пустое, будут обновлены все аккаунты.",
|
||||
"name": "Почтовые адреса"
|
||||
}
|
||||
},
|
||||
"name": "Обновление последнего вызванного сенсора"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "The Forgot Password page was detected. This normally is the result of too many failed logins. Amazon may require action before a relogin can be attempted.",
|
||||
"login_failed": "Alexa Media Player failed to login.",
|
||||
"reauth_successful": "Alexa Media Player successfully reauthenticated. Please ignore the \"Aborted\" message from HA."
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} is invalid",
|
||||
"connection_error": "Error connecting; check network and retry",
|
||||
"identifier_exists": "Email for Alexa URL already registered",
|
||||
"invalid_auth": "Login was not successful. Please double-check your email, password, and Authenticator key.",
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"invalid_url": "URL is invalid: {message}",
|
||||
"oauth_error": "Could not complete OAuth login. Please try again.",
|
||||
"unable_to_connect_hass_url": "Unable to connect to Home Assistant Local URL. Please check the URL under Settings > System > Network > Home Assistant URL > Local network",
|
||||
"unknown_error": "Unknown error: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "Ignore and Continue - I understand that no support for login issues are provided for bypassing this warning."
|
||||
},
|
||||
"description": "The HA server cannot connect to the URL provided: {hass_url}.\n> {error}\n\nTo fix this, please confirm your browser can reach {hass_url}. This field is from Settings > System > Network > Home Assistant URL.\n\nIf you are **certain** your browser can reach this URL, you can bypass this warning.",
|
||||
"title": "Alexa Media Player - Unable to Connect to HA URL"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "Yes, OTP code was verified"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \nHave you verified the OTP code in Amazon 2SV? \n >OTP Code: {message}",
|
||||
"title": "Alexa Media Player - OTP Confirmation"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"email": "Email Address",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"hass_url": "Local network URL to access Home Assistant",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"password": "Password",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"securitycode": "One-time password (OTP)",
|
||||
"should_get_network": "Discover Alexa network",
|
||||
"url": "Amazon region domain (e.g., amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Carbon monoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Indoor air quality"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Particulate matter"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Next reminder"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Next timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repeat"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "YAML configuration of Alexa Media Player is deprecated.\nPlease remove `alexa_media` from your configuration, restart Home Assistant and use the UI to configure it instead.\nSettings > Devices & services > Integrations > ADD INTEGRATION",
|
||||
"title": "YAML configuration is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "Advanced debug",
|
||||
"exclude_devices": "or Exclude these devices from all (comma separated)",
|
||||
"extended_entity_discovery": "Include additional sensors, switches and lights",
|
||||
"include_devices": "Only include these devices (comma separated)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"public_url": "Public URL shared with external hosted services",
|
||||
"queue_delay": "Delay to queue multiple commands together (seconds)",
|
||||
"scan_interval": "Scheduled polling interval (seconds)",
|
||||
"should_get_network": "Discover Alexa network"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* Required entry",
|
||||
"title": "Alexa Media Player - Reconfiguration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "Re-enables Alexa network discovery so the next polling cycle will rediscover Alexa devices for the selected accounts.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Optional Alexa account email or list of emails. If empty, all known accounts will be refreshed.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Enable Network Discovery"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "Force account to logout. Used mainly for debugging.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "Accounts to clear. Empty will clear all.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Force Logout"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "Parses the history records for the specified device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entity to get the history for",
|
||||
"name": "Select media player:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "Number of entries to get",
|
||||
"name": "Number of entries"
|
||||
}
|
||||
},
|
||||
"name": "Get History Records"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "Restore previous volume level on Alexa media player device",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "Entity to restore the previous volume level on",
|
||||
"name": "Select media player:"
|
||||
}
|
||||
},
|
||||
"name": "Restore Previous Volume"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "Forces update of last_called echo device for each Alexa account.",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "List of Alexa accounts to update. If empty, will update all known accounts.",
|
||||
"name": "Email address"
|
||||
}
|
||||
},
|
||||
"name": "Update Last Called Sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"forgot_password": "检测到“忘记密码”页面。这通常是由于多次登录失败导致的。在重新登录之前,亚马逊可能需要采取一些措施。",
|
||||
"login_failed": "Alexa 媒体播放器登录失败。",
|
||||
"reauth_successful": "Alexa 媒体播放器已成功重新验证。请忽略来自 HA 的“Aborted”消息。"
|
||||
},
|
||||
"error": {
|
||||
"2fa_key_invalid": "{otp_secret} 无效",
|
||||
"connection_error": "连接错误;检查网络并重试",
|
||||
"identifier_exists": "Alexa URL的电子邮件已注册",
|
||||
"invalid_auth": "登录失败。请仔细检查您的邮箱、密码和验证码。",
|
||||
"invalid_credentials": "无效的凭证",
|
||||
"invalid_url": "URL 无效: {message}",
|
||||
"oauth_error": "OAuth登录失败,请重试。",
|
||||
"unable_to_connect_hass_url": "无法连接到 Home Assistant 本地 URL。请检查“设置”>“系统”>“网络”>“Home Assistant URL”>“本地网络”中的 URL。",
|
||||
"unknown_error": "未知错误: {message}"
|
||||
},
|
||||
"step": {
|
||||
"proxy_warning": {
|
||||
"data": {
|
||||
"proxy_warning": "忽略并继续 - 我了解不提供对登录问题的支持来绕过此警告。"
|
||||
},
|
||||
"description": "HA 服务器无法连接到提供的 URL:{hass_url}。\n> {error}\n\n要解决此问题,请确认您的浏览器可以访问 {hass_url}。此字段位于“设置”>“系统”>“网络”>“Home Assistant URL”中。\n\n如果您**确定**您的浏览器可以访问此 URL,则可以绕过此警告。",
|
||||
"title": "Alexa 媒体播放器 - 无法连接到 HA URL"
|
||||
},
|
||||
"totp_register": {
|
||||
"data": {
|
||||
"registered": "是的,OTP验证码已验证"
|
||||
},
|
||||
"description": "**{email} - alexa.{url}** \n您是否已在亚马逊 2SV 中验证过 OTP 代码?\n >OTP Code {message}",
|
||||
"title": "Alexa 媒体播放器 - OTP 确认"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"debug": "高级调试",
|
||||
"email": "电子邮件地址",
|
||||
"exclude_devices": "或者将这些设备从所有列表中排除(以逗号分隔)",
|
||||
"extended_entity_discovery": "增加额外的传感器、开关和灯",
|
||||
"hass_url": "用于访问 Home Assistant 的本地网络 URL",
|
||||
"include_devices": "仅包含以下设备(以逗号分隔)",
|
||||
"otp_secret": "亚马逊双重验证的 52 字符身份验证器应用密钥",
|
||||
"password": "密码",
|
||||
"public_url": "与外部托管服务共享的公共 URL",
|
||||
"queue_delay": "将多个命令排队的延迟时间(秒)",
|
||||
"scan_interval": "计划轮询间隔(秒)",
|
||||
"securitycode": "一次性密码(OTP)",
|
||||
"should_get_network": "发现 Alexa 网络",
|
||||
"url": "亚马逊区域域名(例如 amazon.co.uk)"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"air_quality_carbon_monoxide": {
|
||||
"name": "Carbon monoxide"
|
||||
},
|
||||
"air_quality_humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"air_quality_indoor_air_quality": {
|
||||
"name": "Indoor air quality"
|
||||
},
|
||||
"air_quality_particulate_matter": {
|
||||
"name": "Particulate matter"
|
||||
},
|
||||
"air_quality_volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"next_reminder": {
|
||||
"name": "Next reminder"
|
||||
},
|
||||
"next_timer": {
|
||||
"name": "Next timer"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"repeat": {
|
||||
"name": "Repeat"
|
||||
},
|
||||
"shuffle": {
|
||||
"name": "Shuffle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_configuration": {
|
||||
"description": "Alexa Media Player 的 YAML 配置已弃用。\n请从配置中移除 `alexa_media`,重启 Home Assistant,然后改用用户界面进行配置。\n设置 > 设备和服务 > 集成 > 添加集成",
|
||||
"title": "YAML配置已弃用"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"debug": "高级调试",
|
||||
"exclude_devices": "或者将这些设备从所有列表中排除(以逗号分隔)",
|
||||
"extended_entity_discovery": "增加额外的传感器、开关和灯",
|
||||
"include_devices": "仅包含以下设备(以逗号分隔)",
|
||||
"otp_secret": "52-character Authenticator App Key for Amazon 2SV",
|
||||
"public_url": "与外部托管服务共享的公共 URL",
|
||||
"queue_delay": "将多个命令排队的延迟时间(秒)",
|
||||
"scan_interval": "计划轮询频率(秒)",
|
||||
"should_get_network": "发现 Alexa 网络"
|
||||
},
|
||||
"data_description": {
|
||||
"debug": "Enables very verbose, trace-level logging for advanced troubleshooting.\nNot recommended for normal operation due to increased log volume.\nEnsure logger levels are set to DEBUG for full output.",
|
||||
"otp_secret": "Example: 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
|
||||
},
|
||||
"description": "* 必填项",
|
||||
"title": "Alexa 媒体播放器 - 重新配置"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"enable_network_discovery": {
|
||||
"description": "重新启用 Alexa 网络发现功能,以便在下一个轮询周期中重新发现所选帐户的 Alexa 设备。",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "可选的 Alexa 帐户电子邮件地址或电子邮件地址列表。如果为空,则会刷新所有已知帐户。",
|
||||
"name": "电子邮件"
|
||||
}
|
||||
},
|
||||
"name": "启用网络发现"
|
||||
},
|
||||
"force_logout": {
|
||||
"description": "强制帐户注销。主要用于调试。",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "要清除的帐户。清空将清除所有帐户。",
|
||||
"name": "电子邮件地址"
|
||||
}
|
||||
},
|
||||
"name": "强制注销"
|
||||
},
|
||||
"get_history_records": {
|
||||
"description": "解析指定设备的历史记录",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "要获取历史记录的实体",
|
||||
"name": "选择媒体播放器:"
|
||||
},
|
||||
"entries": {
|
||||
"description": "需要获取的条目数量",
|
||||
"name": "条目数量"
|
||||
}
|
||||
},
|
||||
"name": "获取历史记录"
|
||||
},
|
||||
"restore_volume": {
|
||||
"description": "恢复 Alexa 媒体播放器设备上的先前音量级别",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"description": "实体恢复先前的音量水平",
|
||||
"name": "选择媒体播放器:"
|
||||
}
|
||||
},
|
||||
"name": "恢复先前的音量"
|
||||
},
|
||||
"update_last_called": {
|
||||
"description": "强制更新每个 Alexa 帐户的 last_called 回声设备。",
|
||||
"fields": {
|
||||
"email": {
|
||||
"description": "要更新的 Alexa 帐户列表。如果为空,将更新所有已知帐户。",
|
||||
"name": "电子邮件地址"
|
||||
}
|
||||
},
|
||||
"name": "更新上次呼叫传感器"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 426 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 991 KiB |
@@ -0,0 +1,509 @@
|
||||
"""Config + options flow for the HA-MCP custom component.
|
||||
|
||||
One config flow serves two entry types under the shared domain, chosen from a
|
||||
menu on the first step:
|
||||
|
||||
* ``tools`` — the privileged file / YAML services (the original component).
|
||||
A single confirm step creates the entry. Single-instance, keyed on
|
||||
``DOMAIN``.
|
||||
* ``server`` — the in-process ha-mcp FastMCP server (issue #1527). A single
|
||||
confirm step creates the entry (entry-exists = the server runs);
|
||||
single-instance, keyed on ``DOMAIN-server``. Its options flow tunes the
|
||||
channel / port / bind host / webhook auth / pip spec / server URL.
|
||||
|
||||
The two entry types are discriminated by ``entry.data[CONF_ENTRY_TYPE]``; the
|
||||
options-flow dispatcher branches on it so only the server entry gets a
|
||||
configurable options flow (the tools entry aborts with ``no_options``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.loader import async_get_integration
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from .const import (
|
||||
BIND_HOST_ALL,
|
||||
BIND_HOST_LOOPBACK,
|
||||
CHANNEL_DEV,
|
||||
CHANNEL_STABLE,
|
||||
CONF_ENTRY_TYPE,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_WEBHOOK_ID,
|
||||
DEFAULT_AUTO_UPDATE,
|
||||
DEFAULT_BIND_HOST,
|
||||
DEFAULT_CHANNEL,
|
||||
DEFAULT_ENABLE_LLM_API,
|
||||
DEFAULT_LLM_API_EXPOSURE,
|
||||
DEFAULT_LOOPBACK_URL,
|
||||
DEFAULT_PIP_SPEC,
|
||||
DEFAULT_SERVER_PORT,
|
||||
DIST_NAME_DEV,
|
||||
DIST_NAME_STABLE,
|
||||
DOMAIN,
|
||||
ENTRY_TYPE_SERVER,
|
||||
ENTRY_TYPE_TOOLS,
|
||||
EXPOSURE_BOTH,
|
||||
EXPOSURE_FULL,
|
||||
EXPOSURE_TOOL_SEARCH,
|
||||
LLM_API_DOCS_URL,
|
||||
MIN_EMBEDDED_HOME_ASSISTANT_VERSION,
|
||||
OPT_AUTO_UPDATE,
|
||||
OPT_BIND_HOST,
|
||||
OPT_CHANNEL,
|
||||
OPT_ENABLE_LLM_API,
|
||||
OPT_ENABLE_SIDEBAR_PANEL,
|
||||
OPT_ENABLE_STARTUP_NOTIFICATION,
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
OPT_EXTERNAL_URL,
|
||||
OPT_LLM_API_EXPOSURE,
|
||||
OPT_PIP_SPEC,
|
||||
OPT_REGENERATE_SECRETS,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
OPT_SERVER_PORT,
|
||||
OPT_SERVER_URL,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
WEBHOOK_AUTH_HA,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
)
|
||||
|
||||
# Titles shown for each entry in the integration tile's entry list.
|
||||
_TOOLS_ENTRY_TITLE = "HA MCP Tools"
|
||||
_SERVER_ENTRY_TITLE = "HA-MCP Server"
|
||||
|
||||
# The single-instance server entry's unique id — distinct from the tools entry's
|
||||
# unique id (``DOMAIN``) so both entry types coexist under the one domain.
|
||||
_SERVER_UNIQUE_ID = f"{DOMAIN}-server"
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _installed_server_version() -> str | None:
|
||||
"""Return the installed ha-mcp server version, or None if not installed.
|
||||
|
||||
Checks both channel distributions (only one is ever installed at a time).
|
||||
Kept dependency-free (``importlib.metadata``) and swallow-nothing-surprising
|
||||
so a read can never break the options form.
|
||||
"""
|
||||
import importlib.metadata
|
||||
|
||||
for dist in (DIST_NAME_STABLE, DIST_NAME_DEV):
|
||||
try:
|
||||
return importlib.metadata.version(dist)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
||||
"""Handle the config flow for the HA-MCP custom component (both entry types)."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Return the options flow for this entry type.
|
||||
|
||||
Only the in-process server entry has options (channel / port / bind /
|
||||
auth / pip spec / URL). The tools services entry has none, so it returns
|
||||
a flow that aborts with an explanatory message.
|
||||
"""
|
||||
if config_entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
|
||||
return HaMcpServerOptionsFlow()
|
||||
return _NoOptionsFlow()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose which entry type to add: the services tools or the server."""
|
||||
return self.async_show_menu(
|
||||
step_id="user",
|
||||
menu_options=[ENTRY_TYPE_SERVER, ENTRY_TYPE_TOOLS],
|
||||
)
|
||||
|
||||
# -- tools entry: privileged file / YAML services -----------------------
|
||||
|
||||
async def async_step_tools(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Set up the services (tools) entry — single-instance, keyed on DOMAIN.
|
||||
|
||||
Plain confirm-and-create on every install type. (The add-on bootstrap
|
||||
this step used to offer on Supervisor installs was removed: the
|
||||
in-process server entry is the one-click way to get a server, and a
|
||||
second install path only caused confusion. The add-on remains fully
|
||||
supported - installed from the add-on store as always.)
|
||||
"""
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if user_input is not None:
|
||||
return self._create_tools_entry()
|
||||
return self.async_show_form(step_id="tools")
|
||||
|
||||
def _create_tools_entry(self) -> ConfigFlowResult:
|
||||
"""Create the services (tools) config entry."""
|
||||
return self.async_create_entry(
|
||||
title=_TOOLS_ENTRY_TITLE,
|
||||
data={CONF_ENTRY_TYPE: ENTRY_TYPE_TOOLS},
|
||||
)
|
||||
|
||||
# -- server entry: in-process MCP server (issue #1527) ------------------
|
||||
|
||||
async def async_step_server(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm and create the single in-process server entry.
|
||||
|
||||
Creating the entry starts the in-process server with the defaults (port
|
||||
9584, LAN-reachable like the add-on, secret-URL auth); everything is
|
||||
tunable afterward in the integration options.
|
||||
"""
|
||||
try:
|
||||
supported = Version(HA_VERSION) >= Version(
|
||||
MIN_EMBEDDED_HOME_ASSISTANT_VERSION
|
||||
)
|
||||
except InvalidVersion:
|
||||
supported = False
|
||||
if not supported:
|
||||
return self.async_abort(
|
||||
reason="unsupported_home_assistant",
|
||||
description_placeholders={
|
||||
"installed": HA_VERSION,
|
||||
"required": MIN_EMBEDDED_HOME_ASSISTANT_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(_SERVER_UNIQUE_ID)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=_SERVER_ENTRY_TITLE,
|
||||
data={CONF_ENTRY_TYPE: ENTRY_TYPE_SERVER},
|
||||
options={},
|
||||
)
|
||||
return self.async_show_form(step_id="server")
|
||||
|
||||
|
||||
class _NoOptionsFlow(OptionsFlow):
|
||||
"""Options flow for the tools entry: it has no configurable options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort immediately — the services entry exposes no options."""
|
||||
return self.async_abort(reason="no_options")
|
||||
|
||||
|
||||
class HaMcpServerOptionsFlow(OptionsFlow):
|
||||
"""Options flow: configure the in-process MCP server (issue #1527)."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show / apply the server options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=self._normalize(user_input))
|
||||
|
||||
opts = self.config_entry.options
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
OPT_CHANNEL,
|
||||
default=opts.get(OPT_CHANNEL, DEFAULT_CHANNEL),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[CHANNEL_STABLE, CHANNEL_DEV],
|
||||
translation_key="server_channel",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
OPT_AUTO_UPDATE,
|
||||
default=bool(opts.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)),
|
||||
): bool,
|
||||
vol.Required(
|
||||
OPT_SERVER_PORT,
|
||||
default=opts.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
|
||||
vol.Required(
|
||||
OPT_BIND_HOST,
|
||||
default=opts.get(OPT_BIND_HOST, DEFAULT_BIND_HOST),
|
||||
): SelectSelector(
|
||||
# Inline labels: hassfest forbids dots in translation
|
||||
# keys, so the IP-valued options cannot use strings.json
|
||||
# selector translations.
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(
|
||||
value=BIND_HOST_ALL,
|
||||
label="Local network (default)",
|
||||
),
|
||||
SelectOptionDict(
|
||||
value=BIND_HOST_LOOPBACK,
|
||||
label="This machine only (loopback)",
|
||||
),
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
OPT_WEBHOOK_AUTH,
|
||||
default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA],
|
||||
translation_key="server_webhook_auth",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
OPT_PIP_SPEC,
|
||||
# Pre-fill via suggested_value, NOT a schema default: a
|
||||
# default equal to the saved value makes the field
|
||||
# impossible to clear. HA's frontend drops an emptied
|
||||
# optional field from the submitted payload, so voluptuous
|
||||
# re-applies the default (the old override) and clearing
|
||||
# never sticks. suggested_value pre-fills the same value but
|
||||
# is not re-injected on an empty submit. (Applies to every
|
||||
# optional text field below.) Only a genuinely saved
|
||||
# override is suggested; the normalized "no override" state
|
||||
# renders an EMPTY field — the help text says "Leave empty",
|
||||
# and pre-filling DEFAULT_PIP_SPEC would show the STABLE dist
|
||||
# name even on the dev channel.
|
||||
description={"suggested_value": opts.get(OPT_PIP_SPEC, "")},
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_SERVER_URL,
|
||||
description={
|
||||
"suggested_value": opts.get(
|
||||
OPT_SERVER_URL, DEFAULT_LOOPBACK_URL
|
||||
)
|
||||
},
|
||||
): str,
|
||||
vol.Required(
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
default=bool(opts.get(OPT_ENABLE_WEBHOOK, True)),
|
||||
): bool,
|
||||
vol.Required(
|
||||
OPT_ENABLE_STARTUP_NOTIFICATION,
|
||||
default=bool(opts.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)),
|
||||
): bool,
|
||||
vol.Required(
|
||||
OPT_ENABLE_SIDEBAR_PANEL,
|
||||
default=bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True)),
|
||||
): bool,
|
||||
vol.Required(
|
||||
OPT_ENABLE_LLM_API,
|
||||
default=bool(opts.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)),
|
||||
): bool,
|
||||
vol.Required(
|
||||
OPT_LLM_API_EXPOSURE,
|
||||
default=str(
|
||||
opts.get(OPT_LLM_API_EXPOSURE, DEFAULT_LLM_API_EXPOSURE)
|
||||
),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[EXPOSURE_TOOL_SEARCH, EXPOSURE_FULL, EXPOSURE_BOTH],
|
||||
translation_key="llm_api_exposure",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
# suggested_value (not default) so these clear properly on an
|
||||
# empty submit — see the OPT_PIP_SPEC note above.
|
||||
vol.Optional(
|
||||
OPT_EXTERNAL_URL,
|
||||
description={"suggested_value": opts.get(OPT_EXTERNAL_URL, "")},
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
description={
|
||||
"suggested_value": opts.get(OPT_WEBHOOK_ID_OVERRIDE, "")
|
||||
},
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
description={
|
||||
"suggested_value": opts.get(OPT_SECRET_PATH_OVERRIDE, "")
|
||||
},
|
||||
): str,
|
||||
vol.Optional(
|
||||
OPT_REGENERATE_SECRETS,
|
||||
default=False,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
# The sidebar-panel sentence in the description is only truthful while
|
||||
# the panel is registered; drop it (from the CURRENT stored options, not
|
||||
# the unsaved form state) when the panel is off so the link cannot point
|
||||
# at a route that 404s. The trailing space keeps the surrounding prose
|
||||
# spaced correctly whether the sentence is present or empty.
|
||||
panel_hint = (
|
||||
"Open the [HA-MCP settings panel](/ha-mcp) for tool management and "
|
||||
"server settings. "
|
||||
if bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True))
|
||||
else ""
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=schema,
|
||||
description_placeholders={
|
||||
"versions": await self._versions_hint(),
|
||||
"connect_url": self._connect_url_hint(),
|
||||
"llm_api_docs_url": LLM_API_DOCS_URL,
|
||||
"panel_hint": panel_hint,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize(user_input: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize the submitted options before they are persisted.
|
||||
|
||||
Collapses the pip-spec field to empty when it is empty or equals
|
||||
``DEFAULT_PIP_SPEC`` (the unpinned ``ha-mcp`` distribution): the field is
|
||||
pre-filled with the saved override or blank, but a user may also type the
|
||||
default dist name, and persisting it verbatim would read as an
|
||||
intentional override and disable the stable channel's automatic updates.
|
||||
Empty means "no override" (track the selected channel); any other string
|
||||
is a genuine override, stored as-is. Also strips the URL / secret
|
||||
override fields, and drops a blank ``server_url`` so its default applies.
|
||||
"""
|
||||
cleaned = dict(user_input)
|
||||
if cleaned.get(OPT_PIP_SPEC, "").strip() in ("", DEFAULT_PIP_SPEC):
|
||||
cleaned[OPT_PIP_SPEC] = ""
|
||||
for key in (
|
||||
OPT_EXTERNAL_URL,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
):
|
||||
cleaned[key] = str(cleaned.get(key, "") or "").strip()
|
||||
cleaned[OPT_EXTERNAL_URL] = cleaned[OPT_EXTERNAL_URL].rstrip("/")
|
||||
# server_url gets no _normalize-forced empty like the fields above; strip
|
||||
# it and drop it entirely when blank so a whitespace-only value can't be
|
||||
# stored verbatim (it would bypass the consumer's empty -> loopback
|
||||
# fallback and break the HA connection).
|
||||
server_url = str(cleaned.get(OPT_SERVER_URL, "") or "").strip().rstrip("/")
|
||||
if server_url:
|
||||
cleaned[OPT_SERVER_URL] = server_url
|
||||
else:
|
||||
cleaned.pop(OPT_SERVER_URL, None)
|
||||
return cleaned
|
||||
|
||||
async def _versions_hint(self) -> str:
|
||||
"""Return a one-line component + server version summary for the form.
|
||||
|
||||
Reads the component version from the integration manifest and the
|
||||
installed server version from the channel's distribution metadata.
|
||||
Failure-proof like the connect-URL hint: any read error degrades to a
|
||||
best-effort string ("unknown" / "not installed yet") rather than
|
||||
breaking the options form.
|
||||
"""
|
||||
opts = self.config_entry.options
|
||||
channel = str(opts.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
||||
|
||||
component_version = "unknown"
|
||||
hass = getattr(self, "hass", None)
|
||||
if hass is not None:
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
component_version = str(integration.version)
|
||||
except Exception as err:
|
||||
_LOGGER.debug(
|
||||
"Could not read component version for the options hint: %s", err
|
||||
)
|
||||
|
||||
try:
|
||||
# importlib.metadata scans dist-info via os.listdir (blocking I/O),
|
||||
# so run it on the executor rather than the event loop.
|
||||
raw_version = (
|
||||
await hass.async_add_executor_job(_installed_server_version)
|
||||
if hass is not None
|
||||
else _installed_server_version()
|
||||
)
|
||||
server_version = raw_version or "not installed yet"
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Could not read server version for the options hint: %s", err)
|
||||
server_version = "not installed yet"
|
||||
|
||||
return (
|
||||
f"Component {component_version} - "
|
||||
f"Server ha-mcp {server_version} ({channel} channel)"
|
||||
)
|
||||
|
||||
def _connect_url_hint(self) -> str:
|
||||
"""Return the connect URLs for the options form.
|
||||
|
||||
The Configure screen is admin-only, so it shows the real resolved
|
||||
URLs (the start-up notification deliberately does not - it is visible
|
||||
to every signed-in user). Falls back to a placeholder form when
|
||||
resolution is unavailable.
|
||||
"""
|
||||
webhook_id = self.config_entry.data.get(DATA_WEBHOOK_ID)
|
||||
secret_path = self.config_entry.data.get(DATA_SECRET_PATH)
|
||||
if not webhook_id:
|
||||
return (
|
||||
"The connect URLs appear here (and in the Home Assistant log) "
|
||||
"once the server has started."
|
||||
)
|
||||
webhook_enabled = bool(self.config_entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
||||
port = self.config_entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT)
|
||||
hass = getattr(self, "hass", None)
|
||||
if hass is not None:
|
||||
try:
|
||||
from .embedded_setup import build_connect_urls
|
||||
|
||||
urls = build_connect_urls(
|
||||
hass, self.config_entry, webhook_enabled=webhook_enabled
|
||||
)
|
||||
if urls:
|
||||
return "Connect URL(s):\n" + "\n".join(f"- {u}" for u in urls)
|
||||
except Exception as err:
|
||||
# The hint is auxiliary display data: a resolution bug must not
|
||||
# take down the whole options form, but the degradation should
|
||||
# be visible by default - hence warning, not debug.
|
||||
_LOGGER.warning(
|
||||
"Falling back to the placeholder connect-URL hint: %s", err
|
||||
)
|
||||
if not webhook_enabled:
|
||||
# Local-only mode: the webhook endpoint is never registered, so
|
||||
# a webhook URL here would 404. With loopback binding the builder
|
||||
# resolves no URLs at all - state that instead of inventing one.
|
||||
hint = "Remote access via webhook is disabled (local-only mode)."
|
||||
if secret_path:
|
||||
hint += (
|
||||
f"\nDirect access from the Home Assistant machine: "
|
||||
f"http://127.0.0.1:{port}{secret_path}"
|
||||
)
|
||||
return hint
|
||||
external = str(self.config_entry.options.get(OPT_EXTERNAL_URL) or "").rstrip(
|
||||
"/"
|
||||
)
|
||||
base = external or "<your-home-assistant-url>"
|
||||
hint = f"Remote connect URL: {base}/api/webhook/{webhook_id}"
|
||||
if secret_path:
|
||||
hint += (
|
||||
f"\nLocal/LAN (when bind host is 0.0.0.0): "
|
||||
f"http://<home-assistant-ip>:{port}{secret_path}"
|
||||
)
|
||||
return hint
|
||||
@@ -0,0 +1,451 @@
|
||||
"""Constants for the HA-MCP custom component.
|
||||
|
||||
The integration serves two config-entry types under one domain
|
||||
(:data:`DOMAIN`), discriminated by ``entry.data[CONF_ENTRY_TYPE]``:
|
||||
|
||||
* ``tools`` — the privileged file / YAML services (the original component).
|
||||
Pre-existing entries carry no ``entry_type`` key, so a missing value is
|
||||
treated as ``tools`` (no migration needed).
|
||||
* ``server`` — the in-process ha-mcp FastMCP server (issue #1527), exposed
|
||||
through a Home Assistant webhook.
|
||||
|
||||
The two halves keep their constants in separate blocks below; the ``server``
|
||||
block was folded in from the former standalone ``ha_mcp_server`` integration.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "ha_mcp_tools"
|
||||
|
||||
# Component version, kept in lockstep with ``manifest.json``'s ``version``.
|
||||
# ``ha_mcp_tools/info`` reports this so the server can display/debug the running
|
||||
# component build; ``TestManifestVersionParity`` pins the two together so a
|
||||
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
|
||||
# capability negotiation — not this version — gates each WS command (see
|
||||
# ``websocket_api.CAPABILITIES``).
|
||||
COMPONENT_VERSION = "1.1.0"
|
||||
|
||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||
# means "tools" so the pre-existing services entry keeps working across the
|
||||
# component update with no migration.
|
||||
CONF_ENTRY_TYPE = "entry_type"
|
||||
ENTRY_TYPE_TOOLS = "tools"
|
||||
ENTRY_TYPE_SERVER = "server"
|
||||
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_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
|
||||
|
||||
# NON-OVERRIDABLE deny floor for the user-configurable extra read/write
|
||||
# directories (issue #1567). The custom allowlist is applied ON TOP of the
|
||||
# built-in ALLOWED_*_DIRS, but a custom directory can NEVER grant access to
|
||||
# these. The floor is re-checked before any allow decision on every read,
|
||||
# write, list, and delete, so neither a stored entry nor an in-flight one can
|
||||
# punch through it.
|
||||
#
|
||||
# .storage holds HA's auth database (refresh/access tokens), hashed passwords,
|
||||
# and every integration's cleartext credentials (core.config_entries,
|
||||
# application_credentials, cloud) — including this component's OWN caller
|
||||
# token (.storage/ha_mcp_tools_auth). Letting a custom dir reach it would both
|
||||
# leak secrets and hand out the key to this component's own auth gate.
|
||||
DENY_PATH_SEGMENTS = frozenset({".storage"})
|
||||
|
||||
# secrets.yaml is reachable ONLY as the canonical config-root file, where the
|
||||
# read handler masks its values. Any OTHER secrets.yaml surfaced via a custom
|
||||
# dir would be returned UNMASKED (masking keys off the literal root path), so
|
||||
# the floor blocks the basename everywhere except that one canonical location.
|
||||
DENY_READ_BASENAMES = frozenset({"secrets.yaml"})
|
||||
|
||||
# HAOS sibling-volume mounts (issue #1586). These live OUTSIDE the config dir,
|
||||
# so the config-relative custom-directory allowlist (issue #1567) cannot reach
|
||||
# them — its normalizer rejects every absolute path. A user may instead add one
|
||||
# of these fixed absolute roots — or a subdirectory of one — to the custom
|
||||
# directory list; access is then enforced against the volume root exactly as a
|
||||
# config-relative entry is enforced against the config dir (issue #1586).
|
||||
#
|
||||
# The component runs inside HA Core, so a volume is reachable only if the HA
|
||||
# Core container actually mounts it (the standard HAOS/Supervised mounts are
|
||||
# config/share/media/ssl/backup). An unmounted or non-existent root simply
|
||||
# yields a "not found" at use time — adding it is harmless. As with the
|
||||
# config-relative list, a configured volume grants BOTH read and write, and the
|
||||
# non-overridable deny floor (.storage / secrets.yaml) still applies.
|
||||
ALLOWED_VOLUME_ROOTS = ("/share", "/media", "/ssl", "/backup")
|
||||
|
||||
# Files allowed for managed YAML editing
|
||||
ALLOWED_YAML_CONFIG_FILES = ["configuration.yaml"]
|
||||
# Also allows <packages-folder>/*.yaml via pattern matching, where the folder is
|
||||
# the one the user binds under ``homeassistant: packages:`` (default "packages",
|
||||
# detected at runtime — see _detect_package_dirs), plus themes/*.yaml.
|
||||
|
||||
# Top-level YAML keys allowed for editing in any allowed file
|
||||
# (configuration.yaml or packages/*.yaml).
|
||||
# ONLY keys that have no UI/API alternative belong here.
|
||||
# Keys manageable via ha_config_set_helper (input_*, counter, timer, schedule)
|
||||
# are intentionally excluded. automation/script/scene live in
|
||||
# PACKAGES_ONLY_YAML_KEYS below — they have storage-mode equivalents
|
||||
# (ha_config_set_automation/script/scene) but are still exposed in
|
||||
# packages/*.yaml for the YAML-packages workflow.
|
||||
ALLOWED_YAML_KEYS = frozenset(
|
||||
{
|
||||
"template",
|
||||
"sensor",
|
||||
"binary_sensor",
|
||||
"command_line",
|
||||
"rest",
|
||||
"knx",
|
||||
"mqtt",
|
||||
"shell_command",
|
||||
"switch",
|
||||
"light",
|
||||
"fan",
|
||||
"cover",
|
||||
"climate",
|
||||
"notify",
|
||||
"group",
|
||||
"utility_meter",
|
||||
# recorder is YAML-only (no UI or storage-mode helper): purge_keep_days,
|
||||
# include/exclude, commit_interval. Its surface is smaller than keys
|
||||
# already here — it only controls what HA records and for how long, with
|
||||
# no code-execution path like command_line/shell_command/rest (#1852).
|
||||
"recorder",
|
||||
}
|
||||
)
|
||||
|
||||
# Top-level YAML keys allowed ONLY inside packages/*.yaml files, never in
|
||||
# configuration.yaml. Storage-mode UI/API equivalents already exist
|
||||
# (ha_config_set_automation/script/scene), so these are exposed here only
|
||||
# for the YAML-packages workflow used by git-managed configs — where users
|
||||
# expect to keep automations/scripts/scenes alongside templates and other
|
||||
# YAML-defined items. Writes to configuration.yaml for these keys remain
|
||||
# rejected so storage-mode and YAML-mode collections don't collide.
|
||||
PACKAGES_ONLY_YAML_KEYS = frozenset(
|
||||
{
|
||||
"automation",
|
||||
"script",
|
||||
"scene",
|
||||
}
|
||||
)
|
||||
|
||||
# Post-edit action required for each YAML key.
|
||||
# template, mqtt, group, automation, script, and scene have first-party
|
||||
# reload services in HA core. All others require a full HA restart.
|
||||
# ``TestPostActionTableContract`` pins the in-repo shape; the HA-core
|
||||
# side of the contract is a write-time snapshot, not a continuous check.
|
||||
YAML_KEY_POST_ACTIONS: dict[str, dict[str, str]] = {
|
||||
"template": {
|
||||
"post_action": "reload_available",
|
||||
"reload_service": "homeassistant.reload_custom_templates",
|
||||
},
|
||||
"mqtt": {
|
||||
"post_action": "reload_available",
|
||||
"reload_service": "mqtt.reload",
|
||||
},
|
||||
"group": {
|
||||
"post_action": "reload_available",
|
||||
"reload_service": "group.reload",
|
||||
},
|
||||
"automation": {
|
||||
"post_action": "reload_available",
|
||||
"reload_service": "automation.reload",
|
||||
},
|
||||
"script": {
|
||||
"post_action": "reload_available",
|
||||
"reload_service": "script.reload",
|
||||
},
|
||||
"scene": {
|
||||
"post_action": "reload_available",
|
||||
"reload_service": "scene.reload",
|
||||
},
|
||||
}
|
||||
# Default for keys not in YAML_KEY_POST_ACTIONS:
|
||||
YAML_KEY_DEFAULT_POST_ACTION = {"post_action": "restart_required"}
|
||||
|
||||
# YAML-mode dashboard url_path validation (issue #1034).
|
||||
# Pattern: lowercase letters/digits, hyphen-separated, must contain at least
|
||||
# one hyphen (HA's lovelace dashboard rule). No leading/trailing/double hyphens.
|
||||
DASHBOARD_URL_PATH_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)+")
|
||||
|
||||
# url_paths reserved by HA core dashboards/routes — must not be registered as
|
||||
# YAML-mode dashboards or they will shadow / collide with built-ins.
|
||||
RESERVED_DASHBOARD_URL_PATHS = frozenset(
|
||||
{
|
||||
"lovelace",
|
||||
"overview",
|
||||
"map",
|
||||
"logbook",
|
||||
"history",
|
||||
"energy",
|
||||
"developer-tools",
|
||||
"config",
|
||||
"profile",
|
||||
"media-browser",
|
||||
"todo",
|
||||
"calendar",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HA-MCP Server entry (issue #1527)
|
||||
#
|
||||
# Folded in from the former standalone ``ha_mcp_server`` integration. The
|
||||
# "server" config-entry type runs the full ha-mcp FastMCP server in-process
|
||||
# inside Home Assistant (a dedicated thread with its own asyncio loop) and
|
||||
# exposes it remotely through a Home Assistant webhook, exactly like the
|
||||
# webhook-proxy add-on. Creating the entry starts the server; disabling or
|
||||
# removing the entry stops it. Everything below is namespaced under the shared
|
||||
# ``DOMAIN`` (distinct hass.data sub-keys, distinct entry unique_id).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# PyPI distribution names. Stable ships as ``ha-mcp``; the dev channel ships as
|
||||
# ``ha-mcp-dev`` — published on every master push. BOTH are installed unpinned,
|
||||
# so every install / reload resolves the newest build of the selected channel
|
||||
# (the component auto-updates the server rather than pinning a lockstep version
|
||||
# — see ``UPDATE_CHECK_INTERVAL`` and
|
||||
# ``EmbeddedServerManager._async_ensure_package``). Both wheels contain the
|
||||
# *same* ``ha_mcp`` import package (publish-dev.yml only renames the
|
||||
# distribution), so only one may be installed at a time — see
|
||||
# EmbeddedServerManager's channel-switch handling.
|
||||
DIST_NAME_STABLE = "ha-mcp"
|
||||
DIST_NAME_DEV = "ha-mcp-dev"
|
||||
|
||||
# Default pip requirement for the stable channel: the unpinned ``ha-mcp``
|
||||
# distribution, so each install resolves the newest stable release. The options
|
||||
# flow's advanced "pip requirement" field overrides this with any pip spec
|
||||
# (e.g. a version pin or a GitHub tarball URL) for pre-release testing — an
|
||||
# explicit override also disables automatic updates.
|
||||
DEFAULT_PIP_SPEC = DIST_NAME_STABLE
|
||||
DEV_PIP_SPEC = DIST_NAME_DEV
|
||||
|
||||
# Release channels (options-flow selector). ``stable`` installs the unpinned
|
||||
# ``ha-mcp`` and ``dev`` installs the unpinned ``ha-mcp-dev``; both refresh to
|
||||
# the newest build of that channel on every entry reload / HA restart, and the
|
||||
# periodic auto-update check reloads the entry when PyPI publishes a newer one.
|
||||
# An explicit OPT_PIP_SPEC override wins over both and disables auto-update.
|
||||
CHANNEL_STABLE = "stable"
|
||||
CHANNEL_DEV = "dev"
|
||||
DEFAULT_CHANNEL = CHANNEL_STABLE
|
||||
|
||||
|
||||
def dist_for_channel(channel: str) -> str:
|
||||
"""Map a release channel to its PyPI distribution name.
|
||||
|
||||
The channel <-> distribution correspondence is used by the version
|
||||
coordinator, the auto-update notification, and the server manager's pip
|
||||
resolution — one shared mapping so a future third channel cannot be added
|
||||
to some sites and missed in others (review finding on #1760).
|
||||
"""
|
||||
return DIST_NAME_DEV if channel == CHANNEL_DEV else DIST_NAME_STABLE
|
||||
|
||||
|
||||
def channel_for_dist(dist: str) -> str:
|
||||
"""Inverse of :func:`dist_for_channel`."""
|
||||
return CHANNEL_DEV if dist == DIST_NAME_DEV else CHANNEL_STABLE
|
||||
|
||||
|
||||
# Interval of the ServerVersionCoordinator's PyPI poll (coordinator.py). The
|
||||
# poll itself ALWAYS runs — it feeds the `update` platform entity, which must
|
||||
# stay populated even when automatic updates are off (issue #1760). Whether a
|
||||
# newer build actually triggers a reload/reinstall is decided separately, per
|
||||
# refresh, in embedded_setup.async_maybe_auto_update (gated on OPT_AUTO_UPDATE
|
||||
# and on no pip-spec override). Only an explicit pip-spec override skips the
|
||||
# PyPI fetch — comparing PyPI-latest against an arbitrary pip spec is
|
||||
# meaningless.
|
||||
UPDATE_CHECK_INTERVAL = timedelta(hours=6)
|
||||
|
||||
# PyPI JSON API for the latest published version of a distribution. ``{dist}``
|
||||
# is DIST_NAME_STABLE or DIST_NAME_DEV depending on the selected channel.
|
||||
PYPI_JSON_URL = "https://pypi.org/pypi/{dist}/json"
|
||||
|
||||
# The component manifest as it existed at a server release's git tag. Its
|
||||
# ``version`` is the component version that SHIPPED with that server build, so
|
||||
# a value newer than the running component means the release changed the
|
||||
# component too — the pre-install auto-update gate in embedded_setup holds the
|
||||
# server update until HACS delivers the component (issues #1783/#1785).
|
||||
# Tag-timing caveat: stable ``vX.Y.Z`` tags exist before the PyPI publish
|
||||
# (semantic-release pushes the tag first), but a dev ``vX.Y.Z.devN`` tag is
|
||||
# only created when its draft GitHub release is published — AFTER the binary
|
||||
# builds, minutes after PyPI already has the version. During that dev window
|
||||
# this URL 404s and the gate deliberately fails open (the registry's
|
||||
# skip-on-failure is the backstop on that channel).
|
||||
COMPONENT_MANIFEST_AT_TAG_URL = (
|
||||
"https://raw.githubusercontent.com/homeassistant-ai/ha-mcp/"
|
||||
"v{version}/custom_components/ha_mcp_tools/manifest.json"
|
||||
)
|
||||
|
||||
# Options-flow keys (stored in entry.options).
|
||||
OPT_CHANNEL = "channel"
|
||||
# Automatic server-version updates toggle (default on). When on, the channel is
|
||||
# unpinned and auto-updates (force-install on reload/restart + a reload when the
|
||||
# periodic check sees a newer build). When off, the server stays on the version
|
||||
# currently installed: _resolve_pip_spec pins the channel's dist to that version
|
||||
# — but the periodic PyPI check KEEPS running so the update entity still shows
|
||||
# newer builds; its Install button is the manual path (issue #1760). Governs the
|
||||
# ha-mcp server package only — component updates still come through HACS. An
|
||||
# explicit OPT_PIP_SPEC override wins over both and skips the check entirely.
|
||||
OPT_AUTO_UPDATE = "auto_update"
|
||||
DEFAULT_AUTO_UPDATE = True
|
||||
OPT_SERVER_PORT = "server_port"
|
||||
OPT_BIND_HOST = "bind_host"
|
||||
OPT_WEBHOOK_AUTH = "webhook_auth"
|
||||
OPT_PIP_SPEC = "pip_spec"
|
||||
OPT_SERVER_URL = "server_url"
|
||||
# Connect-URL surface + secret management (owner request, parity with the
|
||||
# webhook-proxy app's external-URL option and the add-on's secret-path
|
||||
# override). All optional; empty string = automatic/keep-current.
|
||||
OPT_EXTERNAL_URL = "external_url"
|
||||
OPT_WEBHOOK_ID_OVERRIDE = "webhook_id_override"
|
||||
OPT_SECRET_PATH_OVERRIDE = "secret_path_override"
|
||||
OPT_REGENERATE_SECRETS = "regenerate_secrets"
|
||||
# Local-only mode (owner request): when False, the HA webhook is never
|
||||
# registered, so nothing - including Nabu Casa remote UI - can reach the
|
||||
# server through Home Assistant; only the direct server port (+ the
|
||||
# admin-only sidebar panel, which proxies over loopback) remains.
|
||||
OPT_ENABLE_WEBHOOK = "enable_webhook"
|
||||
# Conversation-agent LLM API (#1745): when False, the toolset is not
|
||||
# registered as a Home Assistant LLM API, so it never appears in any
|
||||
# conversation agent's "Control Home Assistant" selector. On by default —
|
||||
# registering the API only makes it selectable; nothing is exposed until a
|
||||
# user picks it on an agent.
|
||||
OPT_ENABLE_LLM_API = "enable_llm_api"
|
||||
DEFAULT_ENABLE_LLM_API = True
|
||||
# Which exposure shape(s) the LLM API offers to conversation agents:
|
||||
# ``tool_search`` (default) registers a compact API — pinned tools plus
|
||||
# search/execute meta-tools — the shape context-limited models need; ``full``
|
||||
# registers the whole exposed catalog as one API; ``both`` registers the two
|
||||
# side by side so the choice is made per agent in HA's own selector.
|
||||
OPT_LLM_API_EXPOSURE = "llm_api_exposure"
|
||||
EXPOSURE_TOOL_SEARCH = "tool_search"
|
||||
EXPOSURE_FULL = "full"
|
||||
EXPOSURE_BOTH = "both"
|
||||
DEFAULT_LLM_API_EXPOSURE = EXPOSURE_TOOL_SEARCH
|
||||
# When False, the persistent notification created on every server bring-up is
|
||||
# suppressed; the connect URLs still reach the admin-only Home Assistant log.
|
||||
OPT_ENABLE_STARTUP_NOTIFICATION = "enable_startup_notification"
|
||||
# When False, the admin-only "HA-MCP" sidebar settings panel is not registered;
|
||||
# the server's options stay reachable on the entry's Configure screen.
|
||||
OPT_ENABLE_SIDEBAR_PANEL = "enable_sidebar_panel"
|
||||
|
||||
# entry.data keys (persisted ids + secrets; entry.data is fine for secrets).
|
||||
DATA_WEBHOOK_ID = "webhook_id"
|
||||
DATA_SECRET_PATH = "secret_path"
|
||||
DATA_SERVER_USER_ID = "server_user_id"
|
||||
DATA_REFRESH_TOKEN_ID = "refresh_token_id"
|
||||
DATA_ACCESS_TOKEN = "access_token"
|
||||
# Last pip spec that was successfully installed. Lets a changed spec (the
|
||||
# pre-release test channel) force an actual reinstall on the next start instead
|
||||
# of hitting the requirements manager's is-installed shortcut.
|
||||
DATA_LAST_PIP_SPEC = "last_pip_spec"
|
||||
# One-shot marker set by the update entity's Install button (issue #1760):
|
||||
# with auto-update off, EmbeddedServerManager._resolve_pip_spec pins the
|
||||
# channel to the CURRENTLY installed version, so a bare reload would just
|
||||
# reinstall the same build. This pins the next install to a specific version
|
||||
# regardless of auto_update; embedded_server clears it when it CONSUMES it
|
||||
# (before the install attempt) — one marker buys exactly one attempt, so a
|
||||
# failing pinned version can never re-pin later reloads (review finding).
|
||||
DATA_PENDING_INSTALL_VERSION = "pending_install_version"
|
||||
|
||||
# hass.data[DOMAIN] sub-keys for the server runtime. Distinct from the tools
|
||||
# entry's sub-keys ("caller_token" / "allowed_paths") so both entry types can
|
||||
# share hass.data[DOMAIN] without collision.
|
||||
DATA_MANAGER = "manager"
|
||||
DATA_WEBHOOK = "webhook"
|
||||
DATA_BRINGUP_TASK = "bringup_task"
|
||||
# Snapshot of entry.options taken at setup so the update listener reloads only
|
||||
# on a genuine options change — the background bring-up persists ids/token/pip
|
||||
# spec to entry.data, and those writes must not trigger a self-reload.
|
||||
DATA_LAST_OPTIONS = "last_options"
|
||||
# The ServerVersionCoordinator instance backing the `update` platform entity
|
||||
# (issue #1760) — stored so the platform's async_setup_entry can retrieve it.
|
||||
DATA_UPDATE_COORDINATOR = "update_coordinator"
|
||||
# Set by async_maybe_auto_update right before it reloads the entry for an
|
||||
# automatic update ({"old": <version>}): the "server updated" notification must
|
||||
# only fire once the reloaded entry's bring-up actually installed and started
|
||||
# the new build — the reload call returns as soon as entry SETUP finishes,
|
||||
# while the pip install still runs in the background and can fail (review
|
||||
# finding on #1760). Bring-up pops it: notification on success, silent drop on
|
||||
# failure (the package/start repair issues cover that path).
|
||||
DATA_PENDING_UPDATE_NOTIFY = "pending_update_notify"
|
||||
# Unregister callback for the conversation-agent LLM API (#1745), stored by
|
||||
# the bring-up success path and invoked (idempotently) by teardown.
|
||||
DATA_LLM_API_UNSUB = "llm_api_unsub"
|
||||
|
||||
# Webhook auth modes (mirrors the webhook-proxy add-on's default posture).
|
||||
WEBHOOK_AUTH_NONE = "none" # secret webhook URL is the shared secret (default)
|
||||
WEBHOOK_AUTH_HA = "ha_auth" # HA-native bearer (HA core is the OAuth AS)
|
||||
|
||||
# Default bind host + port. 9584 (not the add-on's 9583) so this in-process
|
||||
# server and an add-on install can coexist on the same box.
|
||||
DEFAULT_SERVER_PORT = 9584
|
||||
# LAN-reachable by default - parity with the add-on, whose port has always
|
||||
# been directly reachable with the secret path as the credential. Loopback
|
||||
# is the optional hardening choice, not the default (owner decision).
|
||||
DEFAULT_BIND_HOST = "0.0.0.0"
|
||||
BIND_HOST_ALL = "0.0.0.0"
|
||||
BIND_HOST_LOOPBACK = "127.0.0.1"
|
||||
|
||||
# Loopback base URL the server uses to reach HA core (REST + WS).
|
||||
DEFAULT_LOOPBACK_URL = "http://127.0.0.1:8123"
|
||||
|
||||
# Persistent data dir for the in-process server, under the HA config dir so it
|
||||
# survives restarts and is isolated from an add-on's /data. Generic ".ha_mcp"
|
||||
# to match the merged integration's naming (unreleased server entry, so no
|
||||
# migration from the former ".ha_mcp_server").
|
||||
SERVER_CONFIG_SUBDIR = ".ha_mcp"
|
||||
|
||||
# Client name recorded on the provisioned long-lived access token, and the name
|
||||
# of the local admin user the server logs in as. Stable so a reused token is
|
||||
# recognizable in Settings -> People -> <user> -> tokens. "HA-MCP" phrasing (not
|
||||
# "Home Assistant MCP Server") to avoid confusion with HA's official MCP Server
|
||||
# integration.
|
||||
SERVER_TOKEN_CLIENT_NAME = "HA-MCP Server"
|
||||
SERVER_USER_NAME = "HA-MCP Server"
|
||||
|
||||
# RFC 8414 / RFC 9728 discovery documents for ha_auth mode are served under this
|
||||
# namespace (mirrors the webhook-proxy add-on's /api/mcp_proxy/oauth base).
|
||||
OAUTH_BASE = "/api/ha_mcp_tools/oauth"
|
||||
|
||||
# HACS "add repository" deep link for the custom component. Shared learn_more_url
|
||||
# for every repair issue that ends with "install/reinstall the component via
|
||||
# HACS" (the component-outdated issue and the legacy-HACS-source issue below).
|
||||
HACS_COMPONENT_URL = (
|
||||
"https://my.home-assistant.io/redirect/hacs_repository/"
|
||||
"?owner=homeassistant-ai&repository=ha-mcp-integration&category=integration"
|
||||
)
|
||||
|
||||
# Usage guide for the conversation-agent LLM API option (#1745). Injected into
|
||||
# the options form as a description placeholder — hassfest forbids literal
|
||||
# URLs inside strings.json.
|
||||
LLM_API_DOCS_URL = (
|
||||
"https://github.com/homeassistant-ai/ha-mcp/blob/master/docs/"
|
||||
"in-process-server.md"
|
||||
"#chat-with-the-toolset-from-home-assistant-conversation-agents--voice"
|
||||
)
|
||||
|
||||
# Repair-issue ids surfaced when server bring-up fails.
|
||||
ISSUE_PACKAGE_FAILED = "server_package_install_failed"
|
||||
ISSUE_START_FAILED = "server_start_failed"
|
||||
# Repair issue surfaced when the installed ha-mcp server requires a newer
|
||||
# custom component than the one running. The server package updates
|
||||
# independently of the HACS component, so the running component can lag what
|
||||
# the server expects; this points the user at the HACS component update
|
||||
# (non-blocking).
|
||||
ISSUE_COMPONENT_OUTDATED = "component_outdated"
|
||||
# Repair issue surfaced while an automatic server update is HELD because the
|
||||
# newer server release also shipped a newer custom component than the one
|
||||
# running (issues #1783/#1785): installing that server under the old component
|
||||
# is the combination that broke starts. Held is loud (this issue + a warning
|
||||
# log every check) and escapable — applying the HACS component update (which
|
||||
# takes an HA restart) unblocks the next check, and the update entity's
|
||||
# Install button bypasses the hold entirely.
|
||||
ISSUE_UPDATE_HELD = "server_update_held"
|
||||
# Repair issue surfaced when HACS is tracking the MAIN ha-mcp server repo for
|
||||
# this component (the pre-mirror install path — issue #1760). That install
|
||||
# keeps working (HACS downloads the repo snapshot at the release tag, which
|
||||
# contains the component), but HACS shows the SERVER's version numbers and
|
||||
# release notes, not the component's own; HACS has no repository-migration
|
||||
# mechanism, so this only self-resolves if the user re-adds the dedicated
|
||||
# mirror (homeassistant-ai/ha-mcp-integration).
|
||||
ISSUE_LEGACY_HACS_SOURCE = "legacy_hacs_source"
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Poll the server package's installed vs. latest PyPI version (issue #1760).
|
||||
|
||||
Backs the ``update`` platform entity (:mod:`update`) and the automatic-update
|
||||
decision (:func:`embedded_setup.async_maybe_auto_update`). Runs on
|
||||
:data:`UPDATE_CHECK_INTERVAL` regardless of the ``auto_update`` option — unlike
|
||||
the check this replaces, visibility must not depend on auto-update being on
|
||||
(issue #1760: with auto-update off, users previously got zero signal that a
|
||||
server update existed). Only the resulting *reload* is gated on ``auto_update``,
|
||||
in :mod:`embedded_setup`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import ClientError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
DEFAULT_CHANNEL,
|
||||
DEFAULT_PIP_SPEC,
|
||||
DOMAIN,
|
||||
OPT_CHANNEL,
|
||||
OPT_PIP_SPEC,
|
||||
PYPI_JSON_URL,
|
||||
UPDATE_CHECK_INTERVAL,
|
||||
dist_for_channel,
|
||||
)
|
||||
from .embedded_server import _installed_dist_version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Per-request timeout for the PyPI version-check fetch - short so a slow or
|
||||
# wedged PyPI never ties up the coordinator; a miss just retries next interval
|
||||
# (moved here from the old embedded_setup.async_check_for_update).
|
||||
_PYPI_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerVersionInfo:
|
||||
"""Installed vs. latest server-package version for one config entry."""
|
||||
|
||||
installed: str | None
|
||||
latest: str | None
|
||||
dist: str
|
||||
|
||||
|
||||
class ServerVersionCoordinator(DataUpdateCoordinator[ServerVersionInfo]):
|
||||
"""Poll the installed + PyPI-latest version of the in-process server package.
|
||||
|
||||
Deliberately NOT scoped to the ``auto_update`` option: the update entity
|
||||
must stay populated and the periodic check must keep running even when the
|
||||
user has automatic updates turned off - that visibility is the point of
|
||||
issue #1760. ``embedded_entry`` schedules this coordinator's listener to
|
||||
decide whether to actually reload.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Bind to the config entry and schedule on UPDATE_CHECK_INTERVAL."""
|
||||
self._entry = entry
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=f"{DOMAIN} server version",
|
||||
update_interval=UPDATE_CHECK_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> ServerVersionInfo:
|
||||
"""Return the installed + latest version for the configured channel.
|
||||
|
||||
Never raises ``UpdateFailed`` for an expected PyPI transient - the
|
||||
entity must stay available (showing the installed version) even when
|
||||
PyPI is unreachable; :meth:`_async_fetch_latest`'s own narrow except
|
||||
clause is the only one expected to fire in normal operation.
|
||||
"""
|
||||
options = self._entry.options
|
||||
channel = str(options.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
||||
dist = dist_for_channel(channel)
|
||||
installed = await self.hass.async_add_executor_job(
|
||||
_installed_dist_version, dist
|
||||
)
|
||||
|
||||
override = str(options.get(OPT_PIP_SPEC) or "").strip()
|
||||
if override and override != DEFAULT_PIP_SPEC:
|
||||
# An explicit pip-spec override (a version pin, a tarball URL)
|
||||
# makes a PyPI-latest comparison meaningless - skip the fetch.
|
||||
return ServerVersionInfo(installed=installed, latest=None, dist=dist)
|
||||
|
||||
latest = await self._async_fetch_latest(dist)
|
||||
return ServerVersionInfo(installed=installed, latest=latest, dist=dist)
|
||||
|
||||
async def _async_fetch_latest(self, dist: str) -> str | None:
|
||||
"""Return the newest PyPI version for ``dist``, or None on any failure."""
|
||||
try:
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with asyncio.timeout(_PYPI_TIMEOUT_SECONDS):
|
||||
async with session.get(PYPI_JSON_URL.format(dist=dist)) as resp:
|
||||
resp.raise_for_status()
|
||||
payload = await resp.json()
|
||||
return str(payload["info"]["version"])
|
||||
except (ClientError, TimeoutError, KeyError, ValueError) as err:
|
||||
_LOGGER.debug("HA-MCP server version check failed for %s: %s", dist, err)
|
||||
return None
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Config-entry wiring for the in-process MCP server entry type (issue #1527).
|
||||
|
||||
Runs the full ha-mcp FastMCP server in-process inside Home Assistant and exposes
|
||||
it remotely through a Home Assistant webhook. Creating the "server" config entry
|
||||
starts the server; disabling the entry pauses it (HA calls
|
||||
:func:`async_unload_server_entry` via the domain dispatcher in ``__init__``);
|
||||
removing the entry revokes the provisioned credentials.
|
||||
|
||||
``__init__.async_setup_entry`` dispatches to these functions for the "server"
|
||||
entry type; the "tools" services entry is handled separately. This module is
|
||||
intentionally thin — the HA entry-point wiring only. The bring-up / teardown
|
||||
orchestration lives in :mod:`embedded_setup`, and the server thread + webhook
|
||||
ingress in :mod:`embedded_server` / :mod:`mcp_webhook`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
DATA_BRINGUP_TASK,
|
||||
DATA_LAST_OPTIONS,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_UPDATE_COORDINATOR,
|
||||
DATA_WEBHOOK_ID,
|
||||
DOMAIN,
|
||||
OPT_ENABLE_SIDEBAR_PANEL,
|
||||
OPT_REGENERATE_SECRETS,
|
||||
OPT_SECRET_PATH_OVERRIDE,
|
||||
OPT_WEBHOOK_ID_OVERRIDE,
|
||||
)
|
||||
|
||||
# NOTE: embedded_setup / coordinator (and their embedded_server / mcp_webhook
|
||||
# chain) are imported lazily inside the entry lifecycle functions below, not at
|
||||
# module top level. They pull in aiohttp and several homeassistant.* submodules
|
||||
# (auth, requirements, util.package, components.http/webhook) that the
|
||||
# entry-point wiring here never touches directly, so a top-level import would
|
||||
# make importing this package require that whole stack — breaking hermetic unit
|
||||
# tests that stub only the modules they use.
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
|
||||
async def async_setup_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the server entry: schedule the server bring-up as a background task.
|
||||
|
||||
The bring-up (first pip install of the fastmcp tree, token provisioning,
|
||||
thread start, webhook registration) can take minutes, so it must not stall HA
|
||||
startup. It runs as a config-entry background task — automatically cancelled
|
||||
on unload. The secret webhook id and secret path are generated first, before
|
||||
the update listener is registered, so those ``entry.data`` writes never
|
||||
trigger a mid-setup reload.
|
||||
"""
|
||||
# Imported lazily (see the import note) so the aiohttp / auth / requirements
|
||||
# chain is pulled in only when an entry is actually set up.
|
||||
from .coordinator import ServerVersionCoordinator
|
||||
from .embedded_setup import async_bring_up_server, async_maybe_auto_update
|
||||
from .ui_panel import async_register_ui_panel
|
||||
|
||||
_ensure_secrets(hass, entry)
|
||||
|
||||
# Admin-only "Open Web UI" sidebar panel + proxy. Registered while the entry
|
||||
# exists (its proxy returns 503 until the server is actually running), so the
|
||||
# user sees the panel immediately and it reflects the running state. Gated on
|
||||
# the sidebar-panel option; a change to it reloads the entry, and unload's
|
||||
# unconditional async_unregister_ui_panel then removes the panel this skips.
|
||||
if bool(entry.options.get(OPT_ENABLE_SIDEBAR_PANEL, True)):
|
||||
await async_register_ui_panel(hass)
|
||||
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
# Snapshot the options so the update listener reloads only on a genuine
|
||||
# options change — the background bring-up persists ids/token/pip spec to
|
||||
# entry.data, and those writes must not self-reload.
|
||||
domain_data[DATA_LAST_OPTIONS] = dict(entry.options)
|
||||
|
||||
# Server-version visibility + automatic updates (issue #1760): the
|
||||
# coordinator polls PyPI on its own UPDATE_CHECK_INTERVAL regardless of the
|
||||
# auto_update option, backing the `update` platform entity forwarded below.
|
||||
# Its listener forwards every refresh to async_maybe_auto_update, which
|
||||
# decides whether to actually reload. Created and stored BEFORE the
|
||||
# bring-up task: bring-up's success path (_async_finish_update_cycle)
|
||||
# refreshes this coordinator, so it must already be in hass.data whenever
|
||||
# that task runs.
|
||||
coordinator = ServerVersionCoordinator(hass, entry)
|
||||
domain_data[DATA_UPDATE_COORDINATOR] = coordinator
|
||||
|
||||
task = entry.async_create_background_task(
|
||||
hass, async_bring_up_server(hass, entry), f"{DOMAIN}_bring_up"
|
||||
)
|
||||
domain_data[DATA_BRINGUP_TASK] = task
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
||||
|
||||
@callback
|
||||
def _on_version_update() -> None:
|
||||
# A reload must never run synchronously from inside this listener
|
||||
# callback: it would unload the UPDATE platform this very coordinator
|
||||
# drives (forwarded below), tearing the coordinator down mid-callback.
|
||||
#
|
||||
# hass-owned, NOT entry.async_create_background_task: entry background
|
||||
# tasks are cancelled by the very unload that async_maybe_auto_update's
|
||||
# reload performs, so an entry-owned task would cancel itself mid-reload
|
||||
# and leave the entry unloaded without ever setting back up (server down
|
||||
# until restart). The interval-timer wiring this replaces ran its checks
|
||||
# as plain hass jobs for the same reason.
|
||||
hass.async_create_background_task(
|
||||
async_maybe_auto_update(hass, entry, coordinator.data),
|
||||
f"{DOMAIN}_server_auto_update",
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_on_version_update))
|
||||
|
||||
# Background, not awaited: entry setup must not block on a PyPI round-trip
|
||||
# (this is why async_config_entry_first_refresh is NOT used here). The
|
||||
# coordinator reschedules itself on UPDATE_CHECK_INTERVAL after this first
|
||||
# refresh completes.
|
||||
entry.async_create_background_task(
|
||||
hass, coordinator.async_refresh(), f"{DOMAIN}_server_version_refresh"
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, [Platform.UPDATE])
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Stop the server + ingress webhook (reload-safe; keeps the provisioned token).
|
||||
|
||||
Unloads the UPDATE platform first so the coordinator's entity is torn down
|
||||
before the coordinator itself is popped from hass.data, then cancels the
|
||||
bring-up task so a still-in-flight install/start is torn down before the
|
||||
explicit teardown runs.
|
||||
"""
|
||||
from .embedded_setup import async_teardown_server # lazy (see import note)
|
||||
from .ui_panel import async_unregister_ui_panel
|
||||
|
||||
await hass.config_entries.async_unload_platforms(entry, [Platform.UPDATE])
|
||||
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
task = domain_data.pop(DATA_BRINGUP_TASK, None)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
await async_teardown_server(hass)
|
||||
async_unregister_ui_panel(hass)
|
||||
domain_data.pop(DATA_LAST_OPTIONS, None)
|
||||
domain_data.pop(DATA_UPDATE_COORDINATOR, None)
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Revoke the provisioned credentials when the server config entry is removed."""
|
||||
from .embedded_setup import ( # lazy (see import note)
|
||||
async_revoke_credentials_on_remove,
|
||||
)
|
||||
|
||||
await async_revoke_credentials_on_remove(hass, entry)
|
||||
|
||||
|
||||
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload the entry when its OPTIONS change (port / auth / pip spec / URL).
|
||||
|
||||
Ignores the ``entry.data`` writes the background bring-up performs (webhook
|
||||
id, secret path, provisioned token ids, last pip spec): those fire the same
|
||||
update listener but must not reload the entry.
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
if domain_data.get(DATA_LAST_OPTIONS) == dict(entry.options):
|
||||
return
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Generate + persist the stable webhook id and secret path on first setup.
|
||||
|
||||
Both live in ``entry.data`` and stay stable across restarts so the connect
|
||||
URL never changes. Three owner-requested management paths, applied in
|
||||
priority order on every (re)load:
|
||||
|
||||
1. ``regenerate_secrets`` option: mint fresh random values for BOTH and
|
||||
clear any overrides plus the flag itself (one-shot rotation - the old
|
||||
URL dies on this reload).
|
||||
2. Override options: a non-empty ``webhook_id_override`` /
|
||||
``secret_path_override`` replaces the stored value (normalized: the
|
||||
secret path gets a leading ``/``).
|
||||
3. First setup: mint random values for whatever is still missing.
|
||||
"""
|
||||
data = dict(entry.data)
|
||||
options = dict(entry.options)
|
||||
changed = False
|
||||
|
||||
if options.get(OPT_REGENERATE_SECRETS):
|
||||
data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}"
|
||||
data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}"
|
||||
# One-shot: clear the flag AND the overrides so the fresh random
|
||||
# values stick (leaving an override set would re-apply it below on
|
||||
# the next reload, silently undoing the rotation).
|
||||
options[OPT_REGENERATE_SECRETS] = False
|
||||
options[OPT_WEBHOOK_ID_OVERRIDE] = ""
|
||||
options[OPT_SECRET_PATH_OVERRIDE] = ""
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
return
|
||||
|
||||
webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip()
|
||||
if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override:
|
||||
data[DATA_WEBHOOK_ID] = webhook_override
|
||||
changed = True
|
||||
path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip()
|
||||
if path_override:
|
||||
if not path_override.startswith("/"):
|
||||
path_override = f"/{path_override}"
|
||||
if data.get(DATA_SECRET_PATH) != path_override:
|
||||
data[DATA_SECRET_PATH] = path_override
|
||||
changed = True
|
||||
|
||||
if not data.get(DATA_WEBHOOK_ID):
|
||||
data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}"
|
||||
changed = True
|
||||
if not data.get(DATA_SECRET_PATH):
|
||||
data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}"
|
||||
changed = True
|
||||
if changed:
|
||||
hass.config_entries.async_update_entry(entry, data=data)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,713 @@
|
||||
"""Bring the in-process ha-mcp server up and down for the config entry (#1527).
|
||||
|
||||
Orchestration between :mod:`embedded_server` (the server thread + token
|
||||
provisioning) and :mod:`mcp_webhook` (the ingress webhook): the bring-up sequence,
|
||||
repair issues on failure, connect-URL surfacing, and teardown. Kept out of
|
||||
``__init__.py`` so the entry-point wiring stays thin and this logic is
|
||||
independently testable.
|
||||
|
||||
Every failure here is contained: a failure files a repair issue and returns
|
||||
rather than propagating out of the background bring-up task, so the rest of Home
|
||||
Assistant keeps running even when the server can't be installed or started.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import ClientError
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from .const import (
|
||||
BIND_HOST_ALL,
|
||||
CHANNEL_DEV,
|
||||
COMPONENT_MANIFEST_AT_TAG_URL,
|
||||
DATA_BRINGUP_TASK,
|
||||
DATA_MANAGER,
|
||||
DATA_PENDING_UPDATE_NOTIFY,
|
||||
DATA_SECRET_PATH,
|
||||
DATA_UPDATE_COORDINATOR,
|
||||
DATA_WEBHOOK_ID,
|
||||
DEFAULT_AUTO_UPDATE,
|
||||
DEFAULT_BIND_HOST,
|
||||
DEFAULT_ENABLE_LLM_API,
|
||||
DEFAULT_PIP_SPEC,
|
||||
DEFAULT_SERVER_PORT,
|
||||
DOMAIN,
|
||||
HACS_COMPONENT_URL,
|
||||
ISSUE_COMPONENT_OUTDATED,
|
||||
ISSUE_PACKAGE_FAILED,
|
||||
ISSUE_START_FAILED,
|
||||
ISSUE_UPDATE_HELD,
|
||||
OPT_AUTO_UPDATE,
|
||||
OPT_BIND_HOST,
|
||||
OPT_ENABLE_LLM_API,
|
||||
OPT_ENABLE_SIDEBAR_PANEL,
|
||||
OPT_ENABLE_STARTUP_NOTIFICATION,
|
||||
OPT_ENABLE_WEBHOOK,
|
||||
OPT_EXTERNAL_URL,
|
||||
OPT_PIP_SPEC,
|
||||
OPT_SERVER_PORT,
|
||||
OPT_WEBHOOK_AUTH,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
channel_for_dist,
|
||||
)
|
||||
from .embedded_server import EmbeddedServerError, EmbeddedServerManager
|
||||
from .llm_api import async_register_llm_api, async_unregister_llm_api
|
||||
from .mcp_webhook import async_register_webhook, async_unregister_webhook
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from .coordinator import ServerVersionInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_NOTIFICATION_ID = "ha_mcp_tools_server_connect"
|
||||
_UPDATE_NOTIFICATION_ID = "ha_mcp_tools_server_updated"
|
||||
# ISSUE_UPDATE_HELD is cleared at bring-up start too: any reload that reaches
|
||||
# bring-up either bypassed the hold deliberately (the update entity's Install
|
||||
# button) or made it moot; if the hold still applies, the coordinator refresh
|
||||
# that follows setup re-files it within moments.
|
||||
_ISSUE_IDS = (ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED, ISSUE_UPDATE_HELD)
|
||||
|
||||
# Per-request timeout for the component-manifest fetch behind the auto-update
|
||||
# gate — mirrors the coordinator's PyPI fetch budget; a miss fails open.
|
||||
_MANIFEST_FETCH_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Install, start, and expose the server. Runs as a background task.
|
||||
|
||||
On failure files the matching repair issue and returns — Home Assistant stays
|
||||
up. On cancellation (the entry is being unloaded mid-bring-up) tears down any
|
||||
partial state and re-raises so the task ends cancelled. The secret webhook id
|
||||
and secret path must already exist in ``entry.data`` (the entry setup writes
|
||||
them before scheduling this task).
|
||||
"""
|
||||
_clear_issues(hass)
|
||||
|
||||
manager = EmbeddedServerManager(hass, entry)
|
||||
hass.data.setdefault(DOMAIN, {})[DATA_MANAGER] = manager
|
||||
|
||||
try:
|
||||
await manager.async_start()
|
||||
|
||||
# The package is installed and importable now: verify the running
|
||||
# component satisfies the server's MIN_COMPONENT_VERSION and file/clear
|
||||
# the component-outdated repair issue. Advisory only — it never blocks
|
||||
# the (already started) server.
|
||||
await _async_check_component_compat(hass, entry)
|
||||
|
||||
auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE))
|
||||
secret_path = str(entry.data[DATA_SECRET_PATH])
|
||||
webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
||||
# Always set up the loopback forwarding config — the sidebar settings
|
||||
# panel proxies through it (#1803); the option gates only the public
|
||||
# webhook endpoint.
|
||||
await async_register_webhook(
|
||||
hass,
|
||||
entry,
|
||||
port=manager.port,
|
||||
secret_path=secret_path,
|
||||
auth_mode=auth_mode,
|
||||
register_endpoint=webhook_enabled,
|
||||
)
|
||||
if not webhook_enabled:
|
||||
_LOGGER.info(
|
||||
"Webhook access disabled by option - the server is local-only "
|
||||
"(direct port + sidebar panel)"
|
||||
)
|
||||
_surface_connect_urls(hass, entry, auth_mode, webhook_enabled=webhook_enabled)
|
||||
# Conversation-agent LLM API (#1745), gated on its option (default on).
|
||||
# Advisory: registration failures are contained inside (logged, feature
|
||||
# absent) — the running server must never be taken down by them.
|
||||
if bool(entry.options.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)):
|
||||
await async_register_llm_api(
|
||||
hass, entry, port=manager.port, secret_path=secret_path
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Conversation-agent LLM API disabled by option - the toolset "
|
||||
"will not be offered to Home Assistant conversation agents"
|
||||
)
|
||||
await _async_finish_update_cycle(hass)
|
||||
except asyncio.CancelledError:
|
||||
# Unloaded mid-bring-up: undo whatever partial state exists, then let the
|
||||
# cancellation propagate so the task ends cancelled. The pending
|
||||
# update-notification marker (if any) deliberately survives — it
|
||||
# belongs to a bring-up that has not run yet, not to this one.
|
||||
await async_teardown_server(hass)
|
||||
raise
|
||||
except EmbeddedServerError as err:
|
||||
_LOGGER.error("HA-MCP in-process server failed to start: %s", err)
|
||||
# suppress: filing the repair issue must be UNCONDITIONAL (review
|
||||
# finding) - a raising teardown would otherwise leave the entry
|
||||
# looking healthy with the failure visible only in the log.
|
||||
with suppress(Exception):
|
||||
await async_teardown_server(hass)
|
||||
_create_issue(hass, err.kind, str(err))
|
||||
# The install did not land: never fire the "updated" notification for
|
||||
# it — the repair issue above is the user-facing signal.
|
||||
_drop_pending_update_notify(hass)
|
||||
except Exception as err:
|
||||
_LOGGER.exception("HA-MCP in-process server: bring-up failed")
|
||||
with suppress(Exception):
|
||||
await async_teardown_server(hass)
|
||||
_create_issue(hass, "start", str(err))
|
||||
_drop_pending_update_notify(hass)
|
||||
|
||||
|
||||
async def async_teardown_server(hass: HomeAssistant) -> None:
|
||||
"""Unregister the LLM API + webhook and stop the server thread (reload-safe,
|
||||
idempotent).
|
||||
|
||||
Does NOT revoke the provisioned token — a reload must keep it. The ha_auth
|
||||
discovery views stay bound (aiohttp can't unregister them until HA restarts);
|
||||
they 404 while the entry is not live.
|
||||
"""
|
||||
async_unregister_llm_api(hass)
|
||||
await async_unregister_webhook(hass)
|
||||
manager = hass.data.get(DOMAIN, {}).pop(DATA_MANAGER, None)
|
||||
if isinstance(manager, EmbeddedServerManager):
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
async def async_revoke_credentials_on_remove(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Revoke the provisioned credentials when the config entry is removed."""
|
||||
await EmbeddedServerManager(hass, entry).async_revoke_credentials()
|
||||
_clear_issues(hass)
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
||||
|
||||
|
||||
def build_connect_urls(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
*,
|
||||
webhook_enabled: bool = True,
|
||||
) -> list[str]:
|
||||
"""Resolve the entry's connect URLs (webhook forms first, then direct).
|
||||
|
||||
Shared by the admin-only surfaces that show real URLs: the Home Assistant
|
||||
log on start-up and the entry's Configure screen (the notification
|
||||
deliberately carries none - it is visible to every signed-in user). Each
|
||||
source is best-effort: a URL that cannot be resolved is omitted.
|
||||
"""
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
webhook_id = entry.data.get(DATA_WEBHOOK_ID)
|
||||
urls: list[str] = []
|
||||
external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/")
|
||||
if not webhook_enabled:
|
||||
# Local-only mode: no webhook exists, so no webhook URLs to surface.
|
||||
external = ""
|
||||
webhook_id = None
|
||||
if external:
|
||||
# Owner-requested parity with the webhook-proxy app: a configured
|
||||
# external URL leads the list (any reverse proxy, not just Nabu Casa).
|
||||
urls.append(f"{external}/api/webhook/{webhook_id}")
|
||||
|
||||
# Nabu Casa remote URL (only when the cloud integration is set up + logged in).
|
||||
try:
|
||||
from homeassistant.components.cloud import (
|
||||
CloudNotAvailable,
|
||||
async_remote_ui_url,
|
||||
)
|
||||
|
||||
try:
|
||||
if webhook_id:
|
||||
cloud_base = async_remote_ui_url(hass)
|
||||
urls.append(f"{cloud_base}/api/webhook/{webhook_id}")
|
||||
except CloudNotAvailable:
|
||||
pass # Cloud not logged in / remote UI off - no remote URL to show.
|
||||
except ImportError:
|
||||
pass # Cloud integration not installed (e.g. HA Core) - local URL only.
|
||||
|
||||
local_host: str | None = None
|
||||
try:
|
||||
local_base = get_url(hass, allow_external=False, prefer_external=False)
|
||||
local_host = urlparse(local_base).hostname
|
||||
if webhook_id:
|
||||
urls.append(f"{local_base}/api/webhook/{webhook_id}")
|
||||
except NoURLAvailableError:
|
||||
pass # No internal/local URL configured - fall through to the hint form.
|
||||
|
||||
if not urls and webhook_id:
|
||||
urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)")
|
||||
|
||||
port = int(entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT))
|
||||
bind_host = str(entry.options.get(OPT_BIND_HOST, DEFAULT_BIND_HOST))
|
||||
secret_path = entry.data.get(DATA_SECRET_PATH)
|
||||
if bind_host == BIND_HOST_ALL and secret_path:
|
||||
# Direct-access URL: admin-gated surfaces only (log + Configure screen).
|
||||
# Guarded on the secret path so a missing one omits the line instead of
|
||||
# rendering a valid-looking URL without its credential segment.
|
||||
urls.append(
|
||||
f"http://{local_host or '<home-assistant-ip>'}:{port}{secret_path}"
|
||||
" (direct access)"
|
||||
)
|
||||
return urls
|
||||
|
||||
|
||||
def _surface_connect_urls(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
auth_mode: str,
|
||||
*,
|
||||
webhook_enabled: bool = True,
|
||||
) -> None:
|
||||
"""Log the connect URLs and (re)create a persistent notification."""
|
||||
urls = build_connect_urls(hass, entry, webhook_enabled=webhook_enabled)
|
||||
auth_note = (
|
||||
"Webhook access is disabled (local-only mode)."
|
||||
if not webhook_enabled
|
||||
else "The webhook URL is the shared secret (no bearer required)."
|
||||
if auth_mode == WEBHOOK_AUTH_NONE
|
||||
else "Clients authenticate with your Home Assistant account (ha_auth)."
|
||||
)
|
||||
|
||||
url_lines = "\n".join(f"- {url}" for url in urls)
|
||||
_LOGGER.info(
|
||||
"HA-MCP in-process server is running. Connect URL(s):\n%s\n%s",
|
||||
url_lines,
|
||||
auth_note,
|
||||
)
|
||||
if not bool(entry.options.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)):
|
||||
# Notification suppressed by option: clear any notification created
|
||||
# before the toggle was turned off, then skip creating a fresh one. The
|
||||
# connect URLs still reached the admin-only log above.
|
||||
persistent_notification.async_dismiss(hass, _NOTIFICATION_ID)
|
||||
return
|
||||
# The sidebar-panel line is included only while the panel is registered:
|
||||
# with the panel option off the /ha-mcp route does not exist and the link
|
||||
# would 404.
|
||||
panel_line = (
|
||||
"Manage it from the [HA-MCP settings panel](/ha-mcp) in the sidebar.\n\n"
|
||||
if bool(entry.options.get(OPT_ENABLE_SIDEBAR_PANEL, True))
|
||||
else ""
|
||||
)
|
||||
# SECURITY (review finding): persistent notifications are visible to EVERY
|
||||
# authenticated Home Assistant user - core's persistent_notification/get
|
||||
# and /subscribe carry no admin gate. In the default posture the connect
|
||||
# URL IS an admin-equivalent credential, so the notification deliberately
|
||||
# carries NO secrets: it points at the admin-only surfaces (the sidebar
|
||||
# panel and the entry's Configure screen). The URLs above still go to the
|
||||
# log at INFO, which only admin-gated surfaces expose - the same posture
|
||||
# as the add-on printing its URL to the admin-only add-on log.
|
||||
message = (
|
||||
"The HA-MCP Server is now running inside Home Assistant.\n\n"
|
||||
f"{panel_line}"
|
||||
"The connect URL is shown on the entry's Configure screen "
|
||||
"(Settings - Devices & Services - HA-MCP Custom Component - "
|
||||
"HA-MCP Server - Configure) and in the Home Assistant log - both "
|
||||
"administrator-only, because the URL is the credential.\n\n"
|
||||
f"{auth_note}\n"
|
||||
)
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
message,
|
||||
title="HA-MCP Server",
|
||||
notification_id=_NOTIFICATION_ID,
|
||||
)
|
||||
|
||||
|
||||
_ISSUE_BY_KIND = {
|
||||
"package": ISSUE_PACKAGE_FAILED,
|
||||
"start": ISSUE_START_FAILED,
|
||||
}
|
||||
|
||||
|
||||
def _create_issue(hass: HomeAssistant, kind: str, detail: str) -> None:
|
||||
"""File the repair issue matching the failure ``kind`` (package / start).
|
||||
|
||||
Exhaustive lookup on purpose: an unknown kind is a coding error and must
|
||||
raise here rather than silently filing the wrong user-facing repair issue.
|
||||
"""
|
||||
issue_id = _ISSUE_BY_KIND[kind]
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key=issue_id,
|
||||
translation_placeholders={"detail": detail},
|
||||
)
|
||||
|
||||
|
||||
def _clear_issues(hass: HomeAssistant) -> None:
|
||||
"""Clear any previously-filed server-bring-up repair issues."""
|
||||
for issue_id in _ISSUE_IDS:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automatic server-version updates (channel auto-update)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def async_maybe_auto_update(
|
||||
hass: HomeAssistant, entry: ConfigEntry, info: ServerVersionInfo | None
|
||||
) -> None:
|
||||
"""Reload the entry when ``info`` shows a newer build AND auto-update is on.
|
||||
|
||||
Called from the :class:`~.coordinator.ServerVersionCoordinator` listener
|
||||
registered by :mod:`embedded_entry` on every refresh (every
|
||||
``UPDATE_CHECK_INTERVAL``, plus once shortly after setup). The coordinator
|
||||
itself always fetches (see its docstring) so the `update` platform entity
|
||||
stays populated regardless of this option; only the reload decided here is
|
||||
gated on it.
|
||||
|
||||
Skips entirely when: auto-update is off, a pip-spec override is set,
|
||||
either version is unknown (``info`` may still be ``None`` — the
|
||||
coordinator's ``data`` type before its first successful refresh), or a
|
||||
bring-up is still in flight (below).
|
||||
|
||||
A pending update is additionally gated on component compatibility
|
||||
(issues #1783/#1785): when the candidate release also shipped a newer
|
||||
custom component than the one running, the reload is HELD — loudly (a
|
||||
repair issue plus a warning log every check) and escapably (applying the
|
||||
HACS component update — which takes an HA restart, as the issue text
|
||||
says — unblocks the next check; the update entity's Install button never
|
||||
passes through here, so manual installs — like pip-spec overrides above —
|
||||
bypass the hold entirely). Every failure inside the gate fails OPEN so a
|
||||
GitHub hiccup can never wedge updates.
|
||||
|
||||
Best-effort: an incomparable version string (AwesomeVersionException) is
|
||||
logged at debug and skipped; the next refresh retries. Genuine bugs
|
||||
propagate per the repo's no-silent-failure convention.
|
||||
"""
|
||||
if not bool(entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)):
|
||||
# Auto-update turned off: stay on the currently-installed version.
|
||||
return
|
||||
|
||||
override = str(entry.options.get(OPT_PIP_SPEC) or "").strip()
|
||||
if override and override != DEFAULT_PIP_SPEC:
|
||||
return
|
||||
|
||||
if info is None or info.installed is None or info.latest is None:
|
||||
# Nothing to compare (not installed yet, or the PyPI fetch failed /
|
||||
# was skipped) - the bring-up path installs the newest build itself.
|
||||
return
|
||||
|
||||
bringup_task = hass.data.get(DOMAIN, {}).get(DATA_BRINGUP_TASK)
|
||||
if bringup_task is not None and not bringup_task.done():
|
||||
# The coordinator's first refresh runs shortly after setup, while the
|
||||
# background bring-up (embedded_entry.async_setup_server_entry) may
|
||||
# still be installing the package for the first time. Reloading here
|
||||
# would cancel that in-flight install (async_unload_server_entry
|
||||
# cancels the bring-up task on unload) and can loop: the reload's own
|
||||
# bring-up starts a fresh install that the NEXT refresh could again
|
||||
# interrupt.
|
||||
return
|
||||
|
||||
try:
|
||||
newer = AwesomeVersion(info.latest) > AwesomeVersion(info.installed)
|
||||
except AwesomeVersionException as err:
|
||||
# Incomparable version strategies (e.g. a non-semver build string) — the
|
||||
# only expected failure here. Real bugs (TypeError, etc.) propagate.
|
||||
_LOGGER.debug("HA-MCP auto-update version compare failed: %s", err)
|
||||
return
|
||||
|
||||
if not newer:
|
||||
# Up to date: a hold that was pending is resolved (the component
|
||||
# update landed and the unblocked reload installed the server).
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
|
||||
return
|
||||
|
||||
held = await _async_update_held_by_component(hass, info)
|
||||
if held is not None:
|
||||
shipped, running = held
|
||||
_LOGGER.warning(
|
||||
"HA-MCP server %s is available, but that release also updated the "
|
||||
"custom component (%s; running %s); holding the automatic server "
|
||||
"update until the component is updated via HACS. Press Install on "
|
||||
"the HA-MCP server update entity to install anyway.",
|
||||
info.latest,
|
||||
shipped,
|
||||
running,
|
||||
)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ISSUE_UPDATE_HELD,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_UPDATE_HELD,
|
||||
translation_placeholders={
|
||||
"latest": str(info.latest),
|
||||
"shipped": shipped,
|
||||
"running": running,
|
||||
},
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
)
|
||||
return
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD)
|
||||
|
||||
channel = channel_for_dist(info.dist)
|
||||
_LOGGER.info(
|
||||
"HA-MCP server update available on the %s channel (%s -> %s); "
|
||||
"reloading the entry to install it.",
|
||||
channel,
|
||||
info.installed,
|
||||
info.latest,
|
||||
)
|
||||
# The "updated" notification must wait for the reloaded entry's bring-up to
|
||||
# actually install and start the new build — async_reload returns when
|
||||
# entry SETUP finishes, while the pip install still runs in the background
|
||||
# and can fail (review finding). Leave a marker for bring-up to pop:
|
||||
# notification on success (_async_finish_update_cycle), silent drop on
|
||||
# failure (the package/start repair issues cover that path).
|
||||
hass.data.setdefault(DOMAIN, {})[DATA_PENDING_UPDATE_NOTIFY] = {
|
||||
"old": info.installed
|
||||
}
|
||||
try:
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
except Exception:
|
||||
# A raising reload leaves no repair issue behind (those are filed by
|
||||
# bring-up, which never ran), so this ERROR log is the only signal —
|
||||
# it must not be swallowed or left at debug (review finding). The next
|
||||
# coordinator refresh retries the whole cycle.
|
||||
_drop_pending_update_notify(hass)
|
||||
_LOGGER.exception(
|
||||
"HA-MCP auto-update reload failed (%s -> %s on the %s channel)",
|
||||
info.installed,
|
||||
info.latest,
|
||||
channel,
|
||||
)
|
||||
|
||||
|
||||
def _drop_pending_update_notify(hass: HomeAssistant) -> None:
|
||||
"""Drop the deferred update-notification marker without notifying."""
|
||||
hass.data.get(DOMAIN, {}).pop(DATA_PENDING_UPDATE_NOTIFY, None)
|
||||
|
||||
|
||||
async def _async_update_held_by_component(
|
||||
hass: HomeAssistant, info: ServerVersionInfo
|
||||
) -> tuple[str, str] | None:
|
||||
"""Return ``(shipped, running)`` when the pending update must be held.
|
||||
|
||||
The #1783/#1785 breakage: a server release whose repo state also bumped the
|
||||
custom component auto-installed under the OLD component before HACS had
|
||||
even surfaced the component update. The component version in the manifest
|
||||
at the candidate release's git tag is what shipped with that server build —
|
||||
newer than the running component means the release changed the component
|
||||
too, so the automatic server install waits for the component.
|
||||
|
||||
Fails OPEN (returns None → install proceeds, the pre-gate behavior) on
|
||||
every expected failure: manifest unreachable, component version unreadable,
|
||||
incomparable versions. Blocking updates indefinitely on a transient would
|
||||
be worse than the crash this guards against — and the crash itself is now
|
||||
also survivable server-side (the tools registry skips a failing module).
|
||||
"""
|
||||
shipped = await _async_fetch_shipped_component_version(hass, str(info.latest))
|
||||
if shipped is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
running = str(integration.version)
|
||||
except Exception:
|
||||
# Same wide loader surface as _async_check_component_compat: advisory
|
||||
# gate, logged visibly rather than swallowed silently.
|
||||
_LOGGER.warning(
|
||||
"Could not read the HA-MCP component version for the auto-update "
|
||||
"gate; proceeding with the update",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
if AwesomeVersion(running) < AwesomeVersion(shipped):
|
||||
return shipped, running
|
||||
except AwesomeVersionException as err:
|
||||
# Incomparable version strategies only; real bugs propagate.
|
||||
_LOGGER.debug("HA-MCP auto-update gate version compare failed: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
async def _async_fetch_shipped_component_version(
|
||||
hass: HomeAssistant, server_version: str
|
||||
) -> str | None:
|
||||
"""Return the component version shipped at server release ``vX.Y.Z``.
|
||||
|
||||
Reads the component manifest as committed at the release's git tag (raw
|
||||
GitHub URL). Stable tags exist before the PyPI publish; a dev tag only
|
||||
appears after its binary builds finish, so a fresh dev version can 404
|
||||
here for some minutes — see COMPONENT_MANIFEST_AT_TAG_URL. Returns None
|
||||
on any failure; the caller treats that as "nothing to hold on"
|
||||
(fail-open).
|
||||
"""
|
||||
url = COMPONENT_MANIFEST_AT_TAG_URL.format(version=server_version)
|
||||
try:
|
||||
session = async_get_clientsession(hass)
|
||||
async with asyncio.timeout(_MANIFEST_FETCH_TIMEOUT_SECONDS):
|
||||
async with session.get(url) as resp:
|
||||
resp.raise_for_status()
|
||||
# content_type=None: raw.githubusercontent.com serves
|
||||
# text/plain, which aiohttp's default json() rejects.
|
||||
payload = await resp.json(content_type=None)
|
||||
return str(payload["version"])
|
||||
except (ClientError, TimeoutError, KeyError, TypeError, ValueError) as err:
|
||||
_LOGGER.debug(
|
||||
"HA-MCP shipped-component manifest fetch failed for %s: %s", url, err
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _async_finish_update_cycle(hass: HomeAssistant) -> None:
|
||||
"""Refresh the version entity and fire the deferred update notification.
|
||||
|
||||
Runs at the end of a fully successful bring-up. Both halves belong exactly
|
||||
here (review findings): the freshly installed version is only knowable once
|
||||
the install landed — without a refresh the `update` entity keeps showing a
|
||||
stale "update available" for up to UPDATE_CHECK_INTERVAL after a successful
|
||||
install — and the notification deferred by async_maybe_auto_update must
|
||||
only fire for an install that actually happened. Advisory: a failure here
|
||||
must never fail the (already running) server, so it is logged visibly and
|
||||
swallowed. No reload loop: the refresh's listener re-enters
|
||||
async_maybe_auto_update, which no-ops on the still-running bring-up task.
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
coordinator = domain_data.get(DATA_UPDATE_COORDINATOR)
|
||||
try:
|
||||
if coordinator is not None:
|
||||
await coordinator.async_refresh()
|
||||
except Exception:
|
||||
_LOGGER.warning("HA-MCP: post-install version refresh failed", exc_info=True)
|
||||
marker = domain_data.pop(DATA_PENDING_UPDATE_NOTIFY, None)
|
||||
if marker is None or coordinator is None or coordinator.data is None:
|
||||
return
|
||||
installed = coordinator.data.installed
|
||||
old = marker.get("old")
|
||||
if installed is None or installed == old:
|
||||
# The reload ran but the installed version did not actually move (the
|
||||
# install can legitimately resolve to the same build) — an "updated
|
||||
# to" notification would be false.
|
||||
return
|
||||
_create_update_notification(
|
||||
hass, channel_for_dist(coordinator.data.dist), old, installed
|
||||
)
|
||||
|
||||
|
||||
def _create_update_notification(
|
||||
hass: HomeAssistant, channel: str, old_version: str, new_version: str
|
||||
) -> None:
|
||||
"""Notify that an automatic server update installed and the server is up.
|
||||
|
||||
Only called from :func:`_async_finish_update_cycle` after a successful
|
||||
bring-up, so the versions are the confirmed before/after pair, never a
|
||||
prediction. SECURITY: same posture as ``_surface_connect_urls`` -
|
||||
persistent notifications are visible to every authenticated Home Assistant
|
||||
user, so this carries no secrets or connect URLs, only version numbers and
|
||||
a public GitHub link.
|
||||
"""
|
||||
release_url = (
|
||||
"https://github.com/homeassistant-ai/ha-mcp/commits/master"
|
||||
if channel == CHANNEL_DEV
|
||||
else f"https://github.com/homeassistant-ai/ha-mcp/releases/tag/v{new_version}"
|
||||
)
|
||||
message = (
|
||||
f"The HA-MCP server was automatically updated from {old_version} to "
|
||||
f"{new_version} on the {channel} channel.\n\n"
|
||||
f"[Release notes]({release_url})"
|
||||
)
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
message,
|
||||
title="HA-MCP Server updated",
|
||||
notification_id=_UPDATE_NOTIFICATION_ID,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component / server version-compatibility repair issue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_min_component_version() -> str | None:
|
||||
"""Return the server's declared ``MIN_COMPONENT_VERSION``, or None (blocking).
|
||||
|
||||
Imported here (in an executor thread) so the heavy ``ha_mcp`` import stays
|
||||
off the event loop and out of this module's top level. Guards older/newer
|
||||
server layouts that do not expose the constant by returning None (skip).
|
||||
"""
|
||||
try:
|
||||
from ha_mcp.tools.tools_filesystem import MIN_COMPONENT_VERSION
|
||||
except (ImportError, AttributeError):
|
||||
return None
|
||||
return str(MIN_COMPONENT_VERSION)
|
||||
|
||||
|
||||
async def _async_check_component_compat(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""File/clear the component-outdated repair issue for the running server.
|
||||
|
||||
The ha-mcp server declares the minimum custom-component version it needs
|
||||
(``MIN_COMPONENT_VERSION``). The server package updates independently of
|
||||
the HACS component (this manager pip-installs new server builds), so the
|
||||
running component can lag what the server expects. When it does, surface a
|
||||
WARNING repair issue pointing at the HACS component update; clear it once
|
||||
the component is new enough.
|
||||
|
||||
Advisory only — it must never block or fail server startup, so an
|
||||
unexpected error is logged (visible, not silent) and swallowed rather than
|
||||
propagated to the bring-up's failure handling.
|
||||
"""
|
||||
required = await hass.async_add_executor_job(_read_min_component_version)
|
||||
if required is None:
|
||||
# Server predates MIN_COMPONENT_VERSION, or a newer layout moved it —
|
||||
# nothing to enforce.
|
||||
return
|
||||
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
own = str(integration.version)
|
||||
except Exception:
|
||||
# The loader legitimately raises a wide, varied surface
|
||||
# (IntegrationNotFound, manifest errors); advisory check, logged
|
||||
# visibly with the traceback rather than swallowed silently.
|
||||
_LOGGER.warning(
|
||||
"Could not read the HA-MCP component version for the compatibility check",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
outdated = AwesomeVersion(own) < AwesomeVersion(required)
|
||||
except AwesomeVersionException as err:
|
||||
# Incomparable version strategies only; real bugs propagate.
|
||||
_LOGGER.debug("HA-MCP component-compat version compare failed: %s", err)
|
||||
return
|
||||
|
||||
if outdated:
|
||||
_LOGGER.warning(
|
||||
"The installed ha-mcp server requires HA-MCP Custom Component %s or "
|
||||
"newer, but %s is running; update the component via HACS.",
|
||||
required,
|
||||
own,
|
||||
)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ISSUE_COMPONENT_OUTDATED,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_COMPONENT_OUTDATED,
|
||||
translation_placeholders={"required": required, "installed": own},
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "HA-MCP Custom Component",
|
||||
"render_readme": true
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Detect a legacy (main-repo) HACS install source and warn (issue #1760).
|
||||
|
||||
Before the dedicated HACS mirror (``homeassistant-ai/ha-mcp-integration``)
|
||||
existed, the README told users to add the MAIN ``ha-mcp`` server repository as
|
||||
a HACS custom repository. Those installs still work — HACS downloads the repo
|
||||
snapshot at the release tag, which contains this component — but HACS shows
|
||||
the SERVER's version numbers (``7.x``) and the server/add-on release notes in
|
||||
the update dialog, as if the component were the server itself. HACS has no
|
||||
repository-migration mechanism, so this population stays confused forever
|
||||
unless the component itself detects the legacy source and points them at the
|
||||
mirror. The legacy install keeps working either way — this only files an
|
||||
advisory repair issue, never blocks anything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import CoreState, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, HACS_COMPONENT_URL, ISSUE_LEGACY_HACS_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The main server repo's full_name, as HACS's repository registry keys it —
|
||||
# the legacy (pre-mirror) install path this module detects.
|
||||
_LEGACY_REPO_FULL_NAME = "homeassistant-ai/ha-mcp"
|
||||
|
||||
# Guards the schedule below so multiple config entries (tools + server) on the
|
||||
# same HA run only ever schedule the check once.
|
||||
_DATA_SCHEDULED = "install_source_check_scheduled"
|
||||
|
||||
|
||||
def async_schedule_install_source_check(hass: HomeAssistant) -> None:
|
||||
"""Schedule the legacy-HACS-source check to run once per Home Assistant run.
|
||||
|
||||
Deferred to (or past) Home Assistant startup rather than run at
|
||||
component-setup time: HACS is a separate integration that may set up AFTER
|
||||
this one on a fresh boot, so checking here directly would race it and could
|
||||
misread a legitimate HACS-managed install as "no HACS" before HACS has
|
||||
populated ``hass.data["hacs"]``. ``EVENT_HOMEASSISTANT_STARTED`` only fires
|
||||
once every integration's config entries have finished setup, which is the
|
||||
guarantee this check needs. When hass has already reached that point (a
|
||||
config entry added or reloaded after startup), the event has already fired
|
||||
and never will again this run, so the check runs immediately instead.
|
||||
|
||||
Guarded by a once-flag in ``hass.data[DOMAIN]``: both entry types call this
|
||||
on setup, and this must run at most once per HA run.
|
||||
"""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
if domain_data.get(_DATA_SCHEDULED):
|
||||
return
|
||||
domain_data[_DATA_SCHEDULED] = True
|
||||
|
||||
if hass.state == CoreState.running:
|
||||
hass.async_create_task(
|
||||
_async_check_install_source(hass), f"{DOMAIN}_install_source_check"
|
||||
)
|
||||
return
|
||||
|
||||
@callback
|
||||
def _on_started(_event: Event) -> None:
|
||||
hass.async_create_task(
|
||||
_async_check_install_source(hass), f"{DOMAIN}_install_source_check"
|
||||
)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _on_started)
|
||||
|
||||
|
||||
async def _async_check_install_source(hass: HomeAssistant) -> None:
|
||||
"""File/clear the legacy-HACS-source repair issue.
|
||||
|
||||
Wraps the entire HACS interaction in a broad except: HACS is a third-party
|
||||
integration whose internals this reaches into directly (no public API
|
||||
exists for "what repository is this component tracking"), so any shape
|
||||
change there must degrade to a warning log rather than break Home
|
||||
Assistant. Advisory only — a failure changes nothing in the issue registry.
|
||||
"""
|
||||
try:
|
||||
hacs = hass.data.get("hacs")
|
||||
installed = False
|
||||
if hacs is not None:
|
||||
repo = hacs.repositories.get_by_full_name(_LEGACY_REPO_FULL_NAME)
|
||||
installed = repo is not None and bool(repo.data.installed)
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"HA-MCP: could not determine the HACS install source", exc_info=True
|
||||
)
|
||||
return
|
||||
|
||||
if installed:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ISSUE_LEGACY_HACS_SOURCE,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_LEGACY_HACS_SOURCE,
|
||||
learn_more_url=HACS_COMPONENT_URL,
|
||||
)
|
||||
else:
|
||||
# Not installed via the legacy repo (including: no HACS at all, e.g. a
|
||||
# manual install) — clear any issue filed before the user migrated.
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_HACS_SOURCE)
|
||||
@@ -0,0 +1,680 @@
|
||||
"""Expose the in-process server's toolset as a Home Assistant LLM API (#1745).
|
||||
|
||||
While the in-process server entry is up, the ha-mcp toolset is registered as
|
||||
one or two LLM APIs (``homeassistant.helpers.llm``). Any Home Assistant
|
||||
conversation agent — OpenAI, Google, Ollama, Anthropic, or any other — can
|
||||
then select it in its "Control Home Assistant" option, and the user chats
|
||||
with the toolset through the surfaces Home Assistant already has: the Assist
|
||||
chat UI, the companion apps, and voice satellites. No separate chat frontend
|
||||
is needed.
|
||||
|
||||
Two exposure modes (the ``llm_api_exposure`` entry option picks which are
|
||||
registered; default is tool-search only):
|
||||
|
||||
* **tool search** — the agent gets a tiny catalog: the server's pinned tools
|
||||
mirrored directly, plus two meta-tools synthesized here: ``ha_search_tools``
|
||||
(find tools by task) and ``ha_call_tool`` (execute a discovered tool). This
|
||||
keeps per-turn context small — the shape context-limited models need.
|
||||
* **full** — every exposed tool is mirrored directly into the agent's tool
|
||||
list, one schema each.
|
||||
|
||||
**Per-tool exposure is decided by the server, not here.** The server stamps
|
||||
every ``tools/list`` entry with ``_meta.ha_mcp = {llm_api_exposed, pinned}``
|
||||
(see ``src/ha_mcp/llm_exposure.py``): user toggles from the settings UI, with
|
||||
deny-by-default for beta/developer/restart-reload-backup tools. Both modes
|
||||
filter on the stamp, and the tool-search ``ha_call_tool`` forwarder re-checks
|
||||
it at call time — a hidden tool is invisible (absent from lists and search
|
||||
results) and a hallucinated call to one gets a plain unknown-tool error, the
|
||||
same answer a nonexistent tool gets, so nothing leaks. Globally-disabled
|
||||
tools never appear in ``tools/list`` at all and the server rejects calling
|
||||
them by name, and every forwarded call traverses the server's policy /
|
||||
read-only middleware exactly like any MCP client's call.
|
||||
|
||||
The server runs on its own worker thread behind a loopback HTTP listener, and
|
||||
``ha_mcp`` must never be imported in the HA main process (see
|
||||
:mod:`embedded_server`), so this module talks real MCP to the server over
|
||||
loopback streamable HTTP. The ``mcp`` client SDK arrives with the
|
||||
runtime-installed ha-mcp package (a fastmcp dependency), so every SDK import
|
||||
here is lazy and the first one runs on the executor.
|
||||
|
||||
The tool list is fetched fresh on every ``async_get_api_instance`` call (once
|
||||
per conversation turn): exposure toggles and runtime-registered custom tools
|
||||
apply on the agent's next message, and two loopback round-trips per turn are
|
||||
noise next to the LLM call itself. Tool calls likewise open a short-lived
|
||||
stateless session each — the in-process server serves ``stateless_http=True``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from voluptuous_openapi import convert_to_voluptuous
|
||||
|
||||
from .const import (
|
||||
DATA_LLM_API_UNSUB,
|
||||
DEFAULT_LLM_API_EXPOSURE,
|
||||
DOMAIN,
|
||||
EXPOSURE_BOTH,
|
||||
EXPOSURE_FULL,
|
||||
EXPOSURE_TOOL_SEARCH,
|
||||
OPT_LLM_API_EXPOSURE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
from mcp import types as mcp_types
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Listing tools is two loopback round-trips (initialize + tools/list); a slow
|
||||
# answer means the server thread is wedged, not that the network is slow.
|
||||
_LIST_TOOLS_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# Tool calls run real work — WebSocket-verified device control, dashboard
|
||||
# screenshots, config writes that poll for completion — well beyond the 10s a
|
||||
# remote-server integration would allow. The conversation agent shows a spinner
|
||||
# for the duration, so err generous rather than kill a legitimate slow tool.
|
||||
_CALL_TOOL_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
# The server-side stamp this module filters on (mirrors
|
||||
# src/ha_mcp/llm_exposure.py — keep the names in sync).
|
||||
_META_NAMESPACE = "ha_mcp"
|
||||
_META_EXPOSED_KEY = "llm_api_exposed"
|
||||
_META_PINNED_KEY = "pinned"
|
||||
|
||||
# Fallback exposure policy for servers that predate the stamp: hide the
|
||||
# operational-hazard names and the known beta/developer tools. Imperfect by
|
||||
# construction (a newer beta tool on an old server can't be known here) but
|
||||
# strictly safer than exposing everything, and logged once per instance
|
||||
# build. The real policy lives server-side.
|
||||
_FALLBACK_DENY_PREFIXES = ("ha_dev_",)
|
||||
_FALLBACK_DENY_TOOLS = frozenset(
|
||||
{
|
||||
"ha_restart",
|
||||
"ha_reload_core",
|
||||
"ha_manage_backup",
|
||||
# Beta-tagged tools as of the stamp's introduction (server-side the
|
||||
# gate is tag-based and future-proof; this list is only the legacy
|
||||
# fallback).
|
||||
"ha_config_set_yaml",
|
||||
"ha_manage_custom_tool",
|
||||
"ha_get_dashboard_screenshot",
|
||||
"ha_install_mcp_tools",
|
||||
"ha_list_files",
|
||||
"ha_read_file",
|
||||
"ha_write_file",
|
||||
"ha_delete_file",
|
||||
}
|
||||
)
|
||||
|
||||
# Names of the meta-tools synthesized for the tool-search mode. ha_search_tools
|
||||
# deliberately matches the server's own tool-search terminology; if the server
|
||||
# itself runs ENABLE_TOOL_SEARCH its identically-named tool is excluded from
|
||||
# mirroring/search results to avoid duplicates.
|
||||
_SEARCH_TOOL_NAME = "ha_search_tools"
|
||||
_CALL_TOOL_NAME = "ha_call_tool"
|
||||
_SEARCH_RESULT_LIMIT = 8
|
||||
|
||||
# Used when the server's initialize result carries no instructions (it always
|
||||
# should — ha-mcp ships server-level instructions — but never render an empty
|
||||
# prompt if a build does not).
|
||||
_FALLBACK_API_PROMPT = (
|
||||
"The following tools are provided by the HA-MCP server running inside "
|
||||
"Home Assistant. They give full control over this Home Assistant "
|
||||
"instance: entities, automations, scripts, dashboards, helpers, and "
|
||||
"configuration."
|
||||
)
|
||||
|
||||
_TOOL_SEARCH_PROMPT = (
|
||||
"\n\n## Tool Discovery\n"
|
||||
"This assistant uses search-based tool discovery: most tools are NOT "
|
||||
"listed directly.\n"
|
||||
f"1. Call {_SEARCH_TOOL_NAME}(query=...) to find tools for the task; "
|
||||
"results include each tool's name, description, and input schema.\n"
|
||||
f"2. Execute a discovered tool with {_CALL_TOOL_NAME}(name=..., "
|
||||
"arguments={...}) — discovered tools are NOT directly callable here.\n"
|
||||
"3. The few tools listed directly can be called as usual.\n"
|
||||
"Search once per task, not per call — tool names stay valid all "
|
||||
"conversation."
|
||||
)
|
||||
|
||||
|
||||
def _transport_error_leaves() -> tuple[type[BaseException], ...]:
|
||||
"""Return the non-group exception classes a loopback exchange can raise.
|
||||
|
||||
OSError covers a refused/dropped loopback connect; TimeoutError comes
|
||||
from our asyncio.timeout budget. httpx errors and protocol-level McpError
|
||||
can also escape a session call UNWRAPPED (HA core's mcp integration
|
||||
catches both the same way), but neither class is importable at module
|
||||
level — both arrive with the runtime-installed server package — hence a
|
||||
function instead of a module constant.
|
||||
"""
|
||||
errors: tuple[type[BaseException], ...] = (TimeoutError, OSError)
|
||||
try:
|
||||
import httpx
|
||||
from mcp import McpError
|
||||
except ImportError: # pragma: no cover - SDK-less builds never open a session
|
||||
return errors
|
||||
return (*errors, httpx.HTTPError, McpError)
|
||||
|
||||
|
||||
def _transport_errors() -> tuple[type[BaseException], ...]:
|
||||
"""Return the ``except`` target for one loopback MCP exchange.
|
||||
|
||||
Evaluated at exception time (an ``except`` expression is), so the lazy
|
||||
imports in :func:`_transport_error_leaves` have already succeeded by
|
||||
then. Includes ExceptionGroup because the SDK's anyio task groups wrap
|
||||
in-session failures — but a caught group must still pass
|
||||
:func:`_is_transport_failure` before being mapped to a friendly error,
|
||||
or a genuine bug that happened inside the task group would be relabeled
|
||||
as a transport failure (review finding).
|
||||
"""
|
||||
return (*_transport_error_leaves(), ExceptionGroup)
|
||||
|
||||
|
||||
def _is_transport_failure(err: BaseException) -> bool:
|
||||
"""Return True when ``err`` is purely a transport failure.
|
||||
|
||||
A group counts only when EVERY leaf (nested groups included) is a
|
||||
transport error: a group carrying any non-transport member is a genuine
|
||||
bug that must propagate with its loud traceback instead of being
|
||||
remapped to a "could not reach the server" message.
|
||||
"""
|
||||
if isinstance(err, ExceptionGroup):
|
||||
return all(_is_transport_failure(exc) for exc in err.exceptions)
|
||||
return isinstance(err, _transport_error_leaves())
|
||||
|
||||
|
||||
def _import_mcp_sdk() -> None:
|
||||
"""Import the mcp client SDK modules (blocking; run on the executor).
|
||||
|
||||
Raises ImportError when the SDK is not importable — the caller decides
|
||||
whether that skips registration (SDK missing entirely) or surfaces as a
|
||||
conversation error.
|
||||
"""
|
||||
importlib.import_module("mcp.client.session")
|
||||
importlib.import_module("mcp.client.streamable_http")
|
||||
|
||||
|
||||
async def async_probe_mcp_sdk(hass: HomeAssistant) -> bool:
|
||||
"""Return True when the mcp client SDK imports (first import off-loop)."""
|
||||
try:
|
||||
await hass.async_add_executor_job(_import_mcp_sdk)
|
||||
except ImportError as err:
|
||||
_LOGGER.warning(
|
||||
"The installed server package provides no importable 'mcp' client "
|
||||
"SDK (%s); the conversation-agent LLM API will not be available",
|
||||
err,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _mcp_session(
|
||||
url: str,
|
||||
http_client: Any = None,
|
||||
) -> AsyncIterator[tuple[ClientSession, mcp_types.InitializeResult]]:
|
||||
"""Open an initialized MCP session against the loopback server.
|
||||
|
||||
Imports resolve from ``sys.modules`` — :func:`async_probe_mcp_sdk` did the
|
||||
real (blocking) import on the executor before the API was registered.
|
||||
|
||||
``http_client`` is Home Assistant's shared httpx client
|
||||
(``helpers.httpx_client.get_async_client``). Passing it is what keeps
|
||||
this loop-safe: without it the SDK constructs its own httpx client per
|
||||
session, whose SSL setup loads the CA bundle SYNCHRONOUSLY inside HA's
|
||||
event loop (live-found — HA's blocking-call monitor flagged this exact
|
||||
line). HA's shared client is built against the process-cached SSL
|
||||
context, and the SDK does not close caller-owned clients (HA core's mcp
|
||||
integration relies on the same contract).
|
||||
"""
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
try:
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
transport = (
|
||||
streamable_http_client(url=url, http_client=http_client)
|
||||
if http_client is not None
|
||||
else streamable_http_client(url=url)
|
||||
)
|
||||
except ImportError:
|
||||
# Pre-rename SDK (an older ha-mcp resolved by a pip-spec override
|
||||
# pins an older fastmcp/mcp): same call shape, deprecated name, but
|
||||
# no http_client kwarg — it builds its own client, so on those old
|
||||
# SDKs the blocking-SSL-setup warning is the accepted cost.
|
||||
from mcp.client.streamable_http import (
|
||||
streamablehttp_client,
|
||||
)
|
||||
|
||||
transport = streamablehttp_client(url=url)
|
||||
|
||||
async with (
|
||||
transport as (read_stream, write_stream, _),
|
||||
ClientSession(read_stream, write_stream) as session,
|
||||
):
|
||||
init_result = await session.initialize()
|
||||
yield session, init_result
|
||||
|
||||
|
||||
def _tool_meta_namespace(tool: Any) -> dict[str, Any] | None:
|
||||
"""Return the tool's ``_meta.ha_mcp`` namespace, or None when absent."""
|
||||
meta = getattr(tool, "meta", None)
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
namespace = meta.get(_META_NAMESPACE)
|
||||
return namespace if isinstance(namespace, dict) else None
|
||||
|
||||
|
||||
def _fallback_exposed(name: str) -> bool:
|
||||
"""Legacy exposure policy for servers that predate the meta stamp."""
|
||||
if name.startswith(_FALLBACK_DENY_PREFIXES):
|
||||
return False
|
||||
return name not in _FALLBACK_DENY_TOOLS
|
||||
|
||||
|
||||
def _partition_tools(tools: Iterable[Any]) -> tuple[list[Any], set[str], bool]:
|
||||
"""Split a raw tools/list into (exposed tools, pinned names, stamped).
|
||||
|
||||
``stamped`` is False when NO tool carried the server's exposure stamp —
|
||||
an older server package — in which case the conservative component-side
|
||||
fallback policy was applied instead.
|
||||
"""
|
||||
stamped = False
|
||||
exposed: list[Any] = []
|
||||
pinned: set[str] = set()
|
||||
for tool in tools:
|
||||
namespace = _tool_meta_namespace(tool)
|
||||
if namespace is not None and _META_EXPOSED_KEY in namespace:
|
||||
stamped = True
|
||||
if namespace.get(_META_PINNED_KEY):
|
||||
pinned.add(tool.name)
|
||||
if namespace.get(_META_EXPOSED_KEY):
|
||||
exposed.append(tool)
|
||||
elif _fallback_exposed(tool.name):
|
||||
exposed.append(tool)
|
||||
if not stamped:
|
||||
# The fallback path already filtered; recompute pinned as empty (an
|
||||
# unstamped server gives no pinned signal — the tool-search mode then
|
||||
# simply mirrors nothing directly).
|
||||
pinned = set()
|
||||
return exposed, pinned, stamped
|
||||
|
||||
|
||||
class HaMcpTool(llm.Tool):
|
||||
"""One ha-mcp tool, called over loopback MCP."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str | None,
|
||||
parameters: vol.Schema,
|
||||
server_url: str,
|
||||
) -> None:
|
||||
"""Store the converted schema and the loopback endpoint."""
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parameters = parameters
|
||||
self._server_url = server_url
|
||||
|
||||
async def async_call(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
tool_input: llm.ToolInput,
|
||||
llm_context: llm.LLMContext,
|
||||
) -> JsonObjectType:
|
||||
"""Call the tool on the in-process server and return its result."""
|
||||
return await _forward_tool_call(
|
||||
hass, self._server_url, self.name, tool_input.tool_args
|
||||
)
|
||||
|
||||
|
||||
async def _forward_tool_call(
|
||||
hass: HomeAssistant, server_url: str, name: str, arguments: dict[str, Any]
|
||||
) -> JsonObjectType:
|
||||
"""Forward one tool call over loopback and dump the result for the agent."""
|
||||
try:
|
||||
async with (
|
||||
asyncio.timeout(_CALL_TOOL_TIMEOUT_SECONDS),
|
||||
_mcp_session(server_url, get_async_client(hass)) as (session, _init),
|
||||
):
|
||||
result = await session.call_tool(name, arguments)
|
||||
except _transport_errors() as err:
|
||||
if not _is_transport_failure(err):
|
||||
raise
|
||||
raise HomeAssistantError(
|
||||
f"Error calling the HA-MCP tool {name}: {err}"
|
||||
) from err
|
||||
# Full CallToolResult (content blocks, structuredContent, isError) —
|
||||
# the same shape HA core's mcp integration hands to agents; ha-mcp
|
||||
# signals tool failure via isError + structured error JSON, which the
|
||||
# agent reads and reacts to like any tool output.
|
||||
return result.model_dump(exclude_unset=True, exclude_none=True)
|
||||
|
||||
|
||||
def _search_score(query_words: list[str], name: str, description: str) -> int:
|
||||
"""Score a tool against the query (simple word overlap + substring)."""
|
||||
haystack = f"{name} {description}".lower()
|
||||
name_lower = name.lower()
|
||||
score = 0
|
||||
for word in query_words:
|
||||
if word in name_lower:
|
||||
score += 3
|
||||
elif word in haystack:
|
||||
score += 1
|
||||
return score
|
||||
|
||||
|
||||
class HaMcpSearchTool(llm.Tool):
|
||||
"""Meta-tool: find ha-mcp tools relevant to a task (tool-search mode).
|
||||
|
||||
Searches only the EXPOSED catalog snapshot taken at instance build, so a
|
||||
hidden tool can never appear in results.
|
||||
"""
|
||||
|
||||
name = _SEARCH_TOOL_NAME
|
||||
description = (
|
||||
"Search the Home Assistant MCP toolset for tools relevant to a task. "
|
||||
"Returns each match's name, description, and input schema. Execute "
|
||||
f"matches with {_CALL_TOOL_NAME}."
|
||||
)
|
||||
parameters = vol.Schema({vol.Required("query"): str})
|
||||
|
||||
def __init__(self, catalog: list[dict[str, Any]]) -> None:
|
||||
"""Hold the exposed-catalog snapshot (name/description/schema dicts)."""
|
||||
self._catalog = catalog
|
||||
|
||||
async def async_call(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
tool_input: llm.ToolInput,
|
||||
llm_context: llm.LLMContext,
|
||||
) -> JsonObjectType:
|
||||
"""Return the top-scoring exposed tools for the query."""
|
||||
query_words = [
|
||||
w for w in str(tool_input.tool_args.get("query", "")).lower().split() if w
|
||||
]
|
||||
scored = sorted(
|
||||
(
|
||||
(_search_score(query_words, t["name"], t["description"]), t)
|
||||
for t in self._catalog
|
||||
),
|
||||
key=lambda pair: pair[0],
|
||||
reverse=True,
|
||||
)
|
||||
results = [t for score, t in scored[:_SEARCH_RESULT_LIMIT] if score > 0]
|
||||
if not results:
|
||||
return {
|
||||
"results": [],
|
||||
"message": (
|
||||
"No matching tools. Try different task words (e.g. "
|
||||
"'automation', 'light', 'history', 'dashboard')."
|
||||
),
|
||||
}
|
||||
return {"results": results}
|
||||
|
||||
|
||||
class HaMcpCallTool(llm.Tool):
|
||||
"""Meta-tool: execute a tool discovered via search (tool-search mode).
|
||||
|
||||
The exposure re-check at call time is the enforcement half of the
|
||||
tool-search mode: hiding a tool from search results alone would not stop
|
||||
a model that guesses a name. A non-exposed name gets the same
|
||||
unknown-tool answer a nonexistent name gets — existence never leaks.
|
||||
"""
|
||||
|
||||
name = _CALL_TOOL_NAME
|
||||
description = (
|
||||
"Execute a Home Assistant MCP tool by name with a dictionary of "
|
||||
f"arguments. Discover tools and their schemas with {_SEARCH_TOOL_NAME} "
|
||||
"first."
|
||||
)
|
||||
parameters = vol.Schema(
|
||||
{
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("arguments", default=dict): dict,
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, server_url: str, exposed_names: set[str]) -> None:
|
||||
"""Hold the loopback endpoint and the exposed-name allowlist."""
|
||||
self._server_url = server_url
|
||||
self._exposed_names = exposed_names
|
||||
|
||||
async def async_call(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
tool_input: llm.ToolInput,
|
||||
llm_context: llm.LLMContext,
|
||||
) -> JsonObjectType:
|
||||
"""Forward the call when the target is exposed; unknown-tool otherwise."""
|
||||
name = str(tool_input.tool_args.get("name", ""))
|
||||
arguments = tool_input.tool_args.get("arguments") or {}
|
||||
if name not in self._exposed_names:
|
||||
return {
|
||||
"error": f"Unknown tool '{name}'.",
|
||||
"suggestion": (f"Use {_SEARCH_TOOL_NAME} to discover available tools."),
|
||||
}
|
||||
return await _forward_tool_call(hass, self._server_url, name, arguments)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class HaMcpLlmApi(llm.API):
|
||||
"""The in-process ha-mcp server's toolset as a Home Assistant LLM API."""
|
||||
|
||||
server_url: str
|
||||
# Valid instance modes are only tool_search and full — EXPOSURE_BOTH is
|
||||
# an option value that _apis_for_mode expands into two instances and must
|
||||
# never reach here. The default is the compact/safe shape, matching the
|
||||
# option default (review finding: defaulting to full made an omitted
|
||||
# mode maximally exposed).
|
||||
mode: str = EXPOSURE_TOOL_SEARCH
|
||||
|
||||
async def async_get_api_instance(
|
||||
self, llm_context: llm.LLMContext
|
||||
) -> llm.APIInstance:
|
||||
"""Fetch the current tool list and return an API instance.
|
||||
|
||||
Fetched fresh each conversation turn (see the module docstring); the
|
||||
server's own initialize ``instructions`` become the API prompt, so the
|
||||
agent gets the same guidance every MCP client gets.
|
||||
"""
|
||||
try:
|
||||
async with (
|
||||
asyncio.timeout(_LIST_TOOLS_TIMEOUT_SECONDS),
|
||||
_mcp_session(self.server_url, get_async_client(self.hass)) as (
|
||||
session,
|
||||
init_result,
|
||||
),
|
||||
):
|
||||
list_result = await session.list_tools()
|
||||
except _transport_errors() as err:
|
||||
if not _is_transport_failure(err):
|
||||
raise
|
||||
raise HomeAssistantError(
|
||||
f"Could not reach the in-process HA-MCP server: {err}"
|
||||
) from err
|
||||
|
||||
exposed, pinned, stamped = _partition_tools(list_result.tools)
|
||||
# Never mirror or search a server-side tool that shares a synthesized
|
||||
# meta-tool's name (the server's own tool-search mode registers an
|
||||
# ha_search_tools) — one name, one behavior.
|
||||
exposed = [
|
||||
t for t in exposed if t.name not in (_SEARCH_TOOL_NAME, _CALL_TOOL_NAME)
|
||||
]
|
||||
if not stamped:
|
||||
_LOGGER.warning(
|
||||
"The running server does not stamp LLM-API exposure metadata "
|
||||
"(older ha-mcp package); applying the component's built-in "
|
||||
"conservative deny-list instead. Update the server package "
|
||||
"for per-tool control from the settings UI."
|
||||
)
|
||||
|
||||
prompt = init_result.instructions or _FALLBACK_API_PROMPT
|
||||
# full is the explicit opt-in; anything else — including an unknown
|
||||
# value — falls through to the compact/safe tool-search shape.
|
||||
if self.mode == EXPOSURE_FULL:
|
||||
tools = self._build_full_tools(exposed)
|
||||
else:
|
||||
tools = self._build_tool_search_tools(exposed, pinned)
|
||||
prompt += _TOOL_SEARCH_PROMPT
|
||||
|
||||
return llm.APIInstance(self, prompt, llm_context, tools)
|
||||
|
||||
def _convert_parameters(self, tool: Any) -> vol.Schema | None:
|
||||
"""Convert one tool's JSON schema, or None (logged) when it fails."""
|
||||
try:
|
||||
# cast: voluptuous_openapi is an untyped (ignored) import, so the
|
||||
# call returns Any; its documented return type is vol.Schema.
|
||||
return cast(vol.Schema, convert_to_voluptuous(tool.inputSchema))
|
||||
except Exception:
|
||||
# One unconvertible schema must not take down the whole
|
||||
# toolset for the conversation — skip that tool, loudly.
|
||||
_LOGGER.warning(
|
||||
"Skipping tool %s: could not convert its input schema",
|
||||
tool.name,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _build_full_tools(self, exposed: list[Any]) -> list[llm.Tool]:
|
||||
"""Mirror every exposed tool directly (full-catalog mode)."""
|
||||
tools: list[llm.Tool] = []
|
||||
for tool in exposed:
|
||||
parameters = self._convert_parameters(tool)
|
||||
if parameters is None:
|
||||
continue
|
||||
tools.append(
|
||||
HaMcpTool(tool.name, tool.description, parameters, self.server_url)
|
||||
)
|
||||
return tools
|
||||
|
||||
def _build_tool_search_tools(
|
||||
self, exposed: list[Any], pinned: set[str]
|
||||
) -> list[llm.Tool]:
|
||||
"""Build the compact catalog: mirrored pinned tools + meta-tools."""
|
||||
tools: list[llm.Tool] = []
|
||||
exposed_names: set[str] = set()
|
||||
catalog: list[dict[str, Any]] = []
|
||||
for tool in exposed:
|
||||
exposed_names.add(tool.name)
|
||||
catalog.append(
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description or "",
|
||||
"input_schema": tool.inputSchema,
|
||||
}
|
||||
)
|
||||
if tool.name in pinned:
|
||||
parameters = self._convert_parameters(tool)
|
||||
if parameters is not None:
|
||||
tools.append(
|
||||
HaMcpTool(
|
||||
tool.name, tool.description, parameters, self.server_url
|
||||
)
|
||||
)
|
||||
tools.append(HaMcpSearchTool(catalog))
|
||||
tools.append(HaMcpCallTool(self.server_url, exposed_names))
|
||||
return tools
|
||||
|
||||
|
||||
def _apis_for_mode(
|
||||
hass: HomeAssistant, entry: ConfigEntry, server_url: str, exposure: str
|
||||
) -> list[HaMcpLlmApi]:
|
||||
"""Build the API registration set for the configured exposure mode."""
|
||||
full = HaMcpLlmApi(
|
||||
hass=hass,
|
||||
id=f"{DOMAIN}-{entry.entry_id}",
|
||||
name=entry.title,
|
||||
server_url=server_url,
|
||||
mode=EXPOSURE_FULL,
|
||||
)
|
||||
search = HaMcpLlmApi(
|
||||
hass=hass,
|
||||
id=f"{DOMAIN}-{entry.entry_id}-toolsearch",
|
||||
name=f"{entry.title} (tool search)",
|
||||
server_url=server_url,
|
||||
mode=EXPOSURE_TOOL_SEARCH,
|
||||
)
|
||||
if exposure == EXPOSURE_FULL:
|
||||
return [full]
|
||||
if exposure == EXPOSURE_BOTH:
|
||||
return [full, search]
|
||||
# Default and explicit tool_search both land here; an unknown stored
|
||||
# value degrades to the default rather than failing bring-up.
|
||||
return [search]
|
||||
|
||||
|
||||
async def async_register_llm_api(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
*,
|
||||
port: int,
|
||||
secret_path: str,
|
||||
) -> None:
|
||||
"""Register the toolset as LLM API(s) per the exposure option (advisory).
|
||||
|
||||
Called from the bring-up success path. Never raises — and that has to be
|
||||
literal, not aspirational: any exception escaping here lands in the
|
||||
bring-up's outer ``except Exception``, which tears the already-running
|
||||
server down and files a "start" repair issue for what is a cosmetic
|
||||
failure (review finding). Hence the broad containment: whatever goes
|
||||
wrong is logged and the feature is simply absent until the next (re)load.
|
||||
Cancellation (a BaseException) still propagates.
|
||||
"""
|
||||
try:
|
||||
if not await async_probe_mcp_sdk(hass):
|
||||
return
|
||||
|
||||
# Re-registration guard: a bring-up after a teardown that could not
|
||||
# run (or a duplicate bring-up) must replace the stale registration,
|
||||
# not fail on the duplicate id.
|
||||
async_unregister_llm_api(hass)
|
||||
|
||||
exposure = str(
|
||||
entry.options.get(OPT_LLM_API_EXPOSURE, DEFAULT_LLM_API_EXPOSURE)
|
||||
)
|
||||
server_url = f"http://127.0.0.1:{port}{secret_path}"
|
||||
unsubs = [
|
||||
llm.async_register_api(hass, api)
|
||||
for api in _apis_for_mode(hass, entry, server_url, exposure)
|
||||
]
|
||||
hass.data.setdefault(DOMAIN, {})[DATA_LLM_API_UNSUB] = unsubs
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"Could not register the HA-MCP LLM API; conversation agents will "
|
||||
"not see the toolset until the entry is reloaded",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
# The embedded e2e (test_llm_api_registered_inside_ha) asserts on this
|
||||
# message to prove the registration ran inside a real HA — keep the
|
||||
# "Registered the HA-MCP toolset as LLM API" prefix stable.
|
||||
_LOGGER.info(
|
||||
"Registered the HA-MCP toolset as LLM API (%s mode) — select it in a "
|
||||
"conversation agent's settings to chat with it (text or voice)",
|
||||
exposure,
|
||||
)
|
||||
|
||||
|
||||
def async_unregister_llm_api(hass: HomeAssistant) -> None:
|
||||
"""Unregister the LLM API(s) if registered (idempotent, teardown-safe)."""
|
||||
unsubs = hass.data.get(DOMAIN, {}).pop(DATA_LLM_API_UNSUB, None)
|
||||
if not unsubs:
|
||||
return
|
||||
for unsub in unsubs:
|
||||
unsub()
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"domain": "ha_mcp_tools",
|
||||
"name": "HA-MCP Custom Component",
|
||||
"after_dependencies": [
|
||||
"http",
|
||||
"cloud",
|
||||
"frontend"
|
||||
],
|
||||
"codeowners": [
|
||||
"@homeassistant-ai"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"webhook"
|
||||
],
|
||||
"documentation": "https://github.com/homeassistant-ai/ha-mcp",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/homeassistant-ai/ha-mcp/issues",
|
||||
"requirements": [
|
||||
"ruamel.yaml>=0.18.0"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
"""Webhook ingress for the in-process ha-mcp server (issue #1527).
|
||||
|
||||
Ported from the proven webhook-proxy add-on (``mcp_proxy``): an HA webhook
|
||||
(``/api/webhook/<id>``) forwards MCP traffic to the loopback server and streams
|
||||
the response back, so the server is reachable through Nabu Casa remote UI (or any
|
||||
reverse proxy) with the webhook id as the shared secret.
|
||||
|
||||
Two auth postures, chosen in the options flow:
|
||||
|
||||
* ``none`` — the secret webhook URL *is* the credential (matches the add-on's
|
||||
default). No bearer is required.
|
||||
* ``ha_auth`` — Home Assistant core is the OAuth authorization server. This
|
||||
module serves the RFC 8414 / RFC 9728 discovery documents (so claude.ai /
|
||||
ChatGPT can sign in with the user's HA account) and validates inbound bearer
|
||||
tokens via ``hass.auth``. There is no bespoke authorization-server code here —
|
||||
every protocol step is HA core's own ``/auth/*``.
|
||||
|
||||
The forwarding handler mirrors ``mcp_proxy._handle_webhook`` exactly (hop-by-hop
|
||||
header stripping, the SSE streaming branch with anti-buffering headers, the
|
||||
content-type whitelist, ``Mcp-Session-Id`` propagation, and the 502/500 error
|
||||
mapping); the ``ha_auth`` bearer check + discovery documents mirror the add-on's
|
||||
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.webhook import async_register, async_unregister
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
DATA_WEBHOOK,
|
||||
DATA_WEBHOOK_ID,
|
||||
DOMAIN,
|
||||
OAUTH_BASE,
|
||||
WEBHOOK_AUTH_HA,
|
||||
WEBHOOK_AUTH_NONE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Human-readable webhook name shown in the HA webhook registry.
|
||||
_WEBHOOK_NAME = "HA-MCP in-process server"
|
||||
|
||||
# Hop-by-hop / sensitive request headers never forwarded upstream (identical set
|
||||
# to mcp_proxy). ``authorization`` is stripped because the server authenticates
|
||||
# to HA with its own provisioned token, not the caller's bearer.
|
||||
_STRIPPED_REQUEST_HEADERS = frozenset(
|
||||
{
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"cookie",
|
||||
"authorization",
|
||||
}
|
||||
)
|
||||
|
||||
# Content-Types the forwarded response may carry as-is; anything else is coerced
|
||||
# to JSON to prevent HTML injection / XSS through the proxy. ``text/plain`` is
|
||||
# safe (a browser never executes it) and lets the server's friendly landing page
|
||||
# — a plain-text 405 shown when a browser GETs the endpoint — render as text
|
||||
# instead of a mislabeled JSON blob. ``text/html`` and friends stay coerced.
|
||||
_ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
|
||||
|
||||
# Long timeout for streamed MCP responses (matches mcp_proxy).
|
||||
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300)
|
||||
|
||||
# TOP-LEVEL hass.data flag recording that the ha_auth discovery views are bound
|
||||
# for this HA session. Deliberately NOT under DOMAIN so it survives
|
||||
# async_unload_entry's teardown — aiohttp cannot unregister an HTTP view until HA
|
||||
# restarts, so the views (and this ownership flag) must outlive the config entry.
|
||||
_OAUTH_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_metadata_views_registered"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ha_auth resource server (HA core is the OAuth authorization server)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_base_url(request: web.Request) -> str:
|
||||
"""Build the public base URL from the request (host-derived).
|
||||
|
||||
ha_auth is always host-derived so the SAME install works via the Nabu Casa
|
||||
cloud URL AND any other external URL. Reads ``X-Forwarded-Proto/Host`` as
|
||||
sent: HA's forwarded middleware only validates proxy headers when
|
||||
``X-Forwarded-For`` is present, so these can reach us raw. A peer can
|
||||
thereby only shape the discovery/WWW-Authenticate URLs in its OWN
|
||||
response (no cross-user vector), which is within SECURITY.md's
|
||||
local-network trust model; treat stricter proxy validation as optional
|
||||
hardening.
|
||||
"""
|
||||
host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host", "")
|
||||
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def _authorization_server_document(base: str) -> dict[str, Any]:
|
||||
"""RFC 8414 authorization-server metadata pointing at HA core's OAuth.
|
||||
|
||||
Advertises HA core's own ``/auth/authorize`` + ``/auth/token`` as a public
|
||||
client (``token_endpoint_auth_methods_supported: ["none"]``) and
|
||||
``client_id_metadata_document_supported`` so clients present a URL-shaped
|
||||
``client_id`` (CIMD) that HA core's long-standing IndieAuth handling accepts —
|
||||
the user never pastes a credential. No ``registration_endpoint``: HA offers no
|
||||
dynamic client registration; CIMD replaces it.
|
||||
"""
|
||||
return {
|
||||
"issuer": f"{base}{OAUTH_BASE}",
|
||||
"authorization_endpoint": f"{base}/auth/authorize",
|
||||
"token_endpoint": f"{base}/auth/token",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"client_id_metadata_document_supported": True,
|
||||
}
|
||||
|
||||
|
||||
class ResourceServer:
|
||||
"""ha_auth resource server: bearer validation + discovery URL building.
|
||||
|
||||
Owns no signing key, no client credentials, and binds no root views — HA core
|
||||
is the authorization server. Held by the discovery views and the webhook
|
||||
handler.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, webhook_id: str) -> None:
|
||||
"""Bind to the HA instance and this install's webhook id."""
|
||||
self._hass = hass
|
||||
self._webhook_id = webhook_id
|
||||
|
||||
@property
|
||||
def webhook_id(self) -> str:
|
||||
"""This install's private webhook id."""
|
||||
return self._webhook_id
|
||||
|
||||
def resource_url(self, base_url: str) -> str:
|
||||
"""Absolute URL of the protected webhook resource under ``base_url``."""
|
||||
return f"{base_url}/api/webhook/{self._webhook_id}"
|
||||
|
||||
def authorization_server_url(self, base_url: str) -> str:
|
||||
"""Issuer / authorization-server URL under ``base_url``."""
|
||||
return f"{base_url}{OAUTH_BASE}"
|
||||
|
||||
async def validate_request(self, request: web.Request) -> bool:
|
||||
"""Return True iff the request carries a Bearer token HA core accepts.
|
||||
|
||||
A missing/malformed ``Authorization`` header is rejected without touching
|
||||
the validator. ``hass.auth.async_validate_access_token`` is a synchronous
|
||||
``@callback`` in HA core; it is awaited defensively in case a future
|
||||
release makes it a coroutine, and any raise is treated as unauthorized so
|
||||
a crafted token yields a 401 challenge rather than a 500.
|
||||
"""
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.lower().startswith("bearer "):
|
||||
return False
|
||||
token = header[7:].strip()
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
result = self._hass.auth.async_validate_access_token(token)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
except Exception:
|
||||
_LOGGER.debug(
|
||||
"ha_auth: bearer validation raised; treating as unauthorized",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
if result is None:
|
||||
return False
|
||||
# ADMIN-ONLY: the server performs every Home Assistant operation with
|
||||
# its own provisioned ADMIN token, so accepting any valid login would
|
||||
# grant every household member admin-equivalent control. Require an
|
||||
# active, human, administrator account (mirrors the settings panel).
|
||||
user = getattr(result, "user", None)
|
||||
if user is None:
|
||||
return False
|
||||
if getattr(user, "system_generated", False):
|
||||
return False
|
||||
if not getattr(user, "is_active", False):
|
||||
return False
|
||||
return bool(getattr(user, "is_admin", False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RFC 8414 / RFC 9728 discovery views (ha_auth mode only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _active_resource_server(hass: HomeAssistant) -> ResourceServer | None:
|
||||
"""Return the CURRENT entry's ha_auth resource server, or None.
|
||||
|
||||
The discovery views resolve this per request instead of binding a provider
|
||||
at registration time: aiohttp can't drop a bound view until HA restarts, so
|
||||
a remove + re-add of the config entry (which mints a NEW webhook id in the
|
||||
same HA session) would otherwise leave the views advertising the old id.
|
||||
Returns None when no entry is live, the webhook auth mode is not ha_auth,
|
||||
or the public endpoint is disabled (local-only mode constructs no resource
|
||||
server even under ha_auth) — the views then 404 like an unregistered route.
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
return None
|
||||
cfg = domain_data.get(DATA_WEBHOOK)
|
||||
if not isinstance(cfg, dict) or cfg.get("auth_mode") != WEBHOOK_AUTH_HA:
|
||||
return None
|
||||
provider = cfg.get("resource_server")
|
||||
return provider if isinstance(provider, ResourceServer) else None
|
||||
|
||||
|
||||
def _json_not_found() -> web.Response:
|
||||
"""404 JSON body used by stale-but-bound discovery views."""
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
def _protected_resource_document(provider: ResourceServer, base: str) -> dict[str, Any]:
|
||||
"""RFC 9728 protected-resource document for ``provider`` under ``base``."""
|
||||
return {
|
||||
"resource": provider.resource_url(base),
|
||||
"authorization_servers": [provider.authorization_server_url(base)],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": "https://github.com/homeassistant-ai/ha-mcp",
|
||||
}
|
||||
|
||||
|
||||
class _ProtectedResourceMetadataView(HomeAssistantView):
|
||||
"""RFC 9728 Protected Resource Metadata."""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = f"{OAUTH_BASE}/protected-resource"
|
||||
name = "ha_mcp_tools:oauth:protected-resource"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; the provider is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Serve the protected-resource document (or 404 when ha_auth is off)."""
|
||||
provider = _active_resource_server(self._hass)
|
||||
if provider is None:
|
||||
return _json_not_found()
|
||||
return web.json_response(
|
||||
_protected_resource_document(provider, _build_base_url(request))
|
||||
)
|
||||
|
||||
|
||||
class _AuthorizationServerMetadataView(HomeAssistantView):
|
||||
"""RFC 8414 Authorization Server Metadata (points at HA core's OAuth)."""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
url = f"{OAUTH_BASE}/authorization-server"
|
||||
name = "ha_mcp_tools:oauth:authorization-server"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; liveness is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Serve the authorization-server document (or 404 when ha_auth is off)."""
|
||||
if _active_resource_server(self._hass) is None:
|
||||
return _json_not_found()
|
||||
base = _build_base_url(request)
|
||||
return web.json_response(_authorization_server_document(base))
|
||||
|
||||
|
||||
class _WellKnownProtectedResourceView(HomeAssistantView):
|
||||
"""RFC 9728 §3.1 path-scoped Protected Resource Metadata.
|
||||
|
||||
Same document as :class:`_ProtectedResourceMetadataView`, served at the
|
||||
well-known location derived from the webhook resource URL — claude.ai's
|
||||
first fallback probe when the 401's ``resource_metadata`` pointer is
|
||||
missing. The webhook id is a ROUTE PARAMETER (not baked into the path at
|
||||
registration): a remove + re-add of the entry mints a new webhook id in the
|
||||
same HA session, and the bound view must serve whichever id is currently
|
||||
live (404 for any other). Standalone view (not a subclass of the plain
|
||||
document view) because its handler takes the extra route parameter.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = True
|
||||
name = "ha_mcp_tools:oauth:wellknown-protected-resource"
|
||||
url = "/.well-known/oauth-protected-resource/api/webhook/{webhook_id}"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Bind the view to the HA instance; the provider is resolved per request."""
|
||||
self._hass = hass
|
||||
|
||||
async def get(self, request: web.Request, webhook_id: str) -> web.Response:
|
||||
"""Serve the document only for the CURRENT entry's webhook id."""
|
||||
provider = _active_resource_server(self._hass)
|
||||
if provider is None or webhook_id != provider.webhook_id:
|
||||
return _json_not_found()
|
||||
return web.json_response(
|
||||
_protected_resource_document(provider, _build_base_url(request))
|
||||
)
|
||||
|
||||
|
||||
class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView):
|
||||
"""RFC 8414 / OIDC-discovery locations for the AS metadata document.
|
||||
|
||||
Same document as :class:`_AuthorizationServerMetadataView`, registered at the
|
||||
well-known URLs MCP clients actually probe for the issuer.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, url: str, name: str) -> None:
|
||||
"""Bind and set an explicit well-known URL + unique view name."""
|
||||
super().__init__(hass)
|
||||
self.url = url
|
||||
self.name = name
|
||||
|
||||
|
||||
def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
|
||||
"""Build the seven ha_auth discovery-document views (provider-agnostic)."""
|
||||
views: list[HomeAssistantView] = [
|
||||
_ProtectedResourceMetadataView(hass),
|
||||
_AuthorizationServerMetadataView(hass),
|
||||
_WellKnownProtectedResourceView(hass),
|
||||
]
|
||||
for url, name in (
|
||||
(
|
||||
f"/.well-known/oauth-authorization-server{OAUTH_BASE}",
|
||||
"ha_mcp_tools:oauth:wellknown-as-rfc8414",
|
||||
),
|
||||
(
|
||||
f"/.well-known/openid-configuration{OAUTH_BASE}",
|
||||
"ha_mcp_tools:oauth:wellknown-oidc-prefixed",
|
||||
),
|
||||
(
|
||||
f"{OAUTH_BASE}/.well-known/openid-configuration",
|
||||
"ha_mcp_tools:oauth:wellknown-oidc-suffixed",
|
||||
),
|
||||
(
|
||||
f"{OAUTH_BASE}/.well-known/oauth-authorization-server",
|
||||
"ha_mcp_tools:oauth:wellknown-as-suffixed",
|
||||
),
|
||||
):
|
||||
views.append(
|
||||
_WellKnownAuthorizationServerMetadataView(hass, url=url, name=name)
|
||||
)
|
||||
return views
|
||||
|
||||
|
||||
def _register_metadata_views(hass: HomeAssistant) -> None:
|
||||
"""Register the ha_auth discovery views at most once per HA session.
|
||||
|
||||
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add must
|
||||
reuse the already-bound views — they resolve the ACTIVE provider from
|
||||
hass.data per request, so a later entry (even with a new webhook id) is
|
||||
served correctly. The guard flag lives at a top-level hass.data key that
|
||||
survives config-entry teardown.
|
||||
"""
|
||||
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
for view in _metadata_views(hass):
|
||||
hass.http.register_view(view)
|
||||
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
|
||||
|
||||
|
||||
def _build_unauthorized_response(
|
||||
request: web.Request, provider: ResourceServer
|
||||
) -> web.Response:
|
||||
"""Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover.
|
||||
|
||||
Per RFC 9728 §5.1 / MCP spec, the ``resource_metadata`` parameter points to
|
||||
the protected-resource metadata URL where the client finds the authorization
|
||||
server.
|
||||
"""
|
||||
base = _build_base_url(request)
|
||||
metadata_url = f"{base}{OAUTH_BASE}/protected-resource"
|
||||
return web.Response(
|
||||
status=401,
|
||||
text="Unauthorized",
|
||||
headers={
|
||||
"WWW-Authenticate": (
|
||||
f'Bearer realm="HA-MCP", resource_metadata="{metadata_url}"'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook forwarding handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _async_handle_webhook(
|
||||
hass: HomeAssistant, webhook_id: str, request: web.Request
|
||||
) -> web.StreamResponse:
|
||||
"""Forward an MCP request to the loopback server and stream the reply back."""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
cfg = domain_data.get(DATA_WEBHOOK) if isinstance(domain_data, dict) else None
|
||||
if not isinstance(cfg, dict):
|
||||
return web.Response(status=503, text="MCP server is not available")
|
||||
|
||||
# Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth``
|
||||
# = validate the bearer via HA core, and on failure emit the 401 discovery
|
||||
# challenge so the client can start the OAuth flow. Gate on the PROVIDER
|
||||
# (constructed only for ha_auth) rather than a string compare, so the
|
||||
# coupling "provider present <=> ha_auth" has a single owner and an
|
||||
# inconsistent cfg cannot fail open.
|
||||
provider = cfg.get("resource_server")
|
||||
if provider is not None:
|
||||
if not await provider.validate_request(request):
|
||||
return _build_unauthorized_response(request, provider)
|
||||
|
||||
target_url: str = cfg["target_url"]
|
||||
session: aiohttp.ClientSession = cfg["session"]
|
||||
|
||||
body = await request.read()
|
||||
|
||||
forward_headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in _STRIPPED_REQUEST_HEADERS
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=forward_headers,
|
||||
data=body if body else None,
|
||||
) as upstream_resp:
|
||||
content_type = upstream_resp.headers.get("Content-Type", "")
|
||||
|
||||
resp_headers = {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Content-Encoding": "identity",
|
||||
}
|
||||
mcp_session = upstream_resp.headers.get("Mcp-Session-Id")
|
||||
if mcp_session:
|
||||
resp_headers["Mcp-Session-Id"] = mcp_session
|
||||
|
||||
if "text/event-stream" in content_type:
|
||||
# SSE streaming: prevent HA's compression middleware from
|
||||
# buffering/breaking the stream (supervisor#6470).
|
||||
resp_headers["Content-Type"] = "text/event-stream"
|
||||
resp_headers["X-Accel-Buffering"] = "no"
|
||||
response = web.StreamResponse(
|
||||
status=upstream_resp.status, headers=resp_headers
|
||||
)
|
||||
await response.prepare(request)
|
||||
# Once prepare() has sent the 200 + headers, a mid-stream
|
||||
# upstream failure can no longer become a 502 — returning a
|
||||
# fresh Response here would be silently dropped and the client
|
||||
# would see only a truncated stream with no log trail. End the
|
||||
# prepared stream deterministically and log instead.
|
||||
# Count forwarded bytes manually: StreamResponse.body_length
|
||||
# is only assigned in write_eof(), so it is still 0 here.
|
||||
bytes_forwarded = 0
|
||||
try:
|
||||
async for chunk in upstream_resp.content.iter_any():
|
||||
await response.write(chunk)
|
||||
bytes_forwarded += len(chunk)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error(
|
||||
"MCP webhook: upstream dropped mid-stream after %d bytes: %s",
|
||||
bytes_forwarded,
|
||||
err,
|
||||
)
|
||||
with suppress(ConnectionResetError):
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
if not any(ct in content_type for ct in _ALLOWED_CONTENT_TYPES):
|
||||
content_type = "application/json"
|
||||
resp_headers["Content-Type"] = content_type
|
||||
resp_body = await upstream_resp.read()
|
||||
return web.Response(
|
||||
status=upstream_resp.status, body=resp_body, headers=resp_headers
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("MCP webhook: upstream request failed: %s", err)
|
||||
return web.Response(status=502, text="MCP server unavailable")
|
||||
except Exception as err:
|
||||
_LOGGER.exception("MCP webhook: unexpected error: %s", err)
|
||||
return web.Response(status=500, text="MCP server internal error")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration / teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def async_register_webhook(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
*,
|
||||
port: int,
|
||||
secret_path: str,
|
||||
auth_mode: str,
|
||||
register_endpoint: bool = True,
|
||||
) -> None:
|
||||
"""Register the ingress webhook (and, for ha_auth, the discovery views).
|
||||
|
||||
Stores the forwarding config in ``hass.data[DOMAIN][DATA_WEBHOOK]`` and opens
|
||||
a long-lived aiohttp session for streaming. Raises on failure with the webhook
|
||||
already unregistered, so the caller never leaves a half-configured endpoint
|
||||
live. ``webhook`` is a manifest dependency, so HA guarantees it is set up
|
||||
before this runs.
|
||||
|
||||
With ``register_endpoint=False`` (remote webhook access disabled by option)
|
||||
no public endpoint or ha_auth surface is created — and any leftover endpoint
|
||||
from a crashed unload is cleared, so off means off; only the forwarding
|
||||
config is stored, which same-host consumers — the sidebar settings panel
|
||||
proxy — need to reach the loopback server (#1803).
|
||||
"""
|
||||
if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA):
|
||||
# Fail CLOSED on an unknown mode (corrupt/migrated options): refusing
|
||||
# bring-up files a repair issue, instead of an unrecognized string
|
||||
# silently taking the unauthenticated forward path.
|
||||
raise ValueError(f"Unknown webhook auth mode: {auth_mode!r}")
|
||||
|
||||
webhook_id: str = entry.data[DATA_WEBHOOK_ID]
|
||||
# Reload-safe and off-means-off: clear any leftover registration from a
|
||||
# crashed unload before (re)registering — or before storing a local-only
|
||||
# config (async_unregister is a no-op pop when nothing is registered).
|
||||
# Runs before the session opens so a raise here cannot leak it.
|
||||
async_unregister(hass, webhook_id)
|
||||
target_url = f"http://127.0.0.1:{port}{secret_path}"
|
||||
session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT)
|
||||
|
||||
cfg: dict[str, Any] = {
|
||||
"webhook_id": webhook_id,
|
||||
"target_url": target_url,
|
||||
"session": session,
|
||||
"auth_mode": auth_mode,
|
||||
"resource_server": None,
|
||||
}
|
||||
|
||||
if register_endpoint:
|
||||
try:
|
||||
async_register(
|
||||
hass,
|
||||
DOMAIN,
|
||||
_WEBHOOK_NAME,
|
||||
webhook_id,
|
||||
_async_handle_webhook,
|
||||
allowed_methods=["POST", "GET"],
|
||||
)
|
||||
if auth_mode == WEBHOOK_AUTH_HA:
|
||||
provider = ResourceServer(hass, webhook_id)
|
||||
_register_metadata_views(hass)
|
||||
cfg["resource_server"] = provider
|
||||
except Exception:
|
||||
# Never leave a live endpoint (or a leaked session) behind a failed
|
||||
# auth-setup path. suppress: the ORIGINAL error must be what
|
||||
# propagates (review finding) - a raising cleanup would mask it.
|
||||
with suppress(Exception):
|
||||
async_unregister(hass, webhook_id)
|
||||
with suppress(Exception):
|
||||
await session.close()
|
||||
raise
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg
|
||||
|
||||
|
||||
async def async_unregister_webhook(hass: HomeAssistant) -> None:
|
||||
"""Unregister the ingress webhook and close its aiohttp session.
|
||||
|
||||
Idempotent. The ha_auth discovery views are intentionally left bound (aiohttp
|
||||
can't unregister them until HA restarts); they 404 while ha_auth is not live.
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
return
|
||||
cfg = domain_data.pop(DATA_WEBHOOK, None)
|
||||
if not isinstance(cfg, dict):
|
||||
return
|
||||
webhook_id = cfg.get("webhook_id")
|
||||
if webhook_id:
|
||||
async_unregister(hass, webhook_id)
|
||||
session = cfg.get("session")
|
||||
if session is not None:
|
||||
await session.close()
|
||||
@@ -0,0 +1,188 @@
|
||||
edit_yaml_config:
|
||||
name: Edit YAML Config
|
||||
description: >-
|
||||
Add, replace, or remove a top-level key in configuration.yaml,
|
||||
package files, or theme files. Validates YAML, creates backups, and restricts edits
|
||||
to a whitelist of allowed keys.
|
||||
fields:
|
||||
file:
|
||||
name: File
|
||||
description: >-
|
||||
Relative path to the YAML file. Supports configuration.yaml,
|
||||
packages/*.yaml, and themes/*.yaml.
|
||||
required: true
|
||||
example: "configuration.yaml"
|
||||
selector:
|
||||
text:
|
||||
action:
|
||||
name: Action
|
||||
description: "Action to perform: add, replace, or remove."
|
||||
required: true
|
||||
example: "add"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "add"
|
||||
- "replace"
|
||||
- "remove"
|
||||
yaml_path:
|
||||
name: YAML Path
|
||||
description: >-
|
||||
Top-level YAML key to modify (e.g., template, sensor,
|
||||
binary_sensor) or theme name for themes/*.yaml files. Only whitelisted keys are allowed.
|
||||
required: true
|
||||
example: "template"
|
||||
selector:
|
||||
text:
|
||||
content:
|
||||
name: Content
|
||||
description: >-
|
||||
YAML content for the value under yaml_path. Required for add
|
||||
and replace actions.
|
||||
required: false
|
||||
example: "- sensor:\n - name: My Sensor\n state: '{{ now() }}'"
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
backup:
|
||||
name: Backup
|
||||
description: Create a backup before editing. Default is true.
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
list_files:
|
||||
name: List Files
|
||||
description: List files in a directory within the Home Assistant config directory.
|
||||
fields:
|
||||
path:
|
||||
name: Path
|
||||
description: Relative path from config directory (e.g., "www/", "themes/")
|
||||
required: true
|
||||
example: "www/"
|
||||
selector:
|
||||
text:
|
||||
pattern:
|
||||
name: Pattern
|
||||
description: Optional glob pattern to filter files (e.g., "*.css", "*.js")
|
||||
required: false
|
||||
example: "*.css"
|
||||
selector:
|
||||
text:
|
||||
|
||||
read_file:
|
||||
name: Read File
|
||||
description: Read a file from the Home Assistant config directory.
|
||||
fields:
|
||||
path:
|
||||
name: Path
|
||||
description: >-
|
||||
Relative path from config directory. Allowed paths include
|
||||
configuration.yaml, automations.yaml, scripts.yaml, scenes.yaml,
|
||||
secrets.yaml (values masked), home-assistant.log, www/**, themes/**,
|
||||
custom_templates/**, packages/*.yaml, custom_components/**/*.py
|
||||
required: true
|
||||
example: "configuration.yaml"
|
||||
selector:
|
||||
text:
|
||||
tail_lines:
|
||||
name: Tail Lines
|
||||
description: For log files, return only the last N lines. Default is 1000 for logs.
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 10000
|
||||
mode: box
|
||||
|
||||
write_file:
|
||||
name: Write File
|
||||
description: Write a file to allowed directories (www/, themes/, custom_templates/).
|
||||
fields:
|
||||
path:
|
||||
name: Path
|
||||
description: >-
|
||||
Relative path from config directory. Must be in www/, themes/, or
|
||||
custom_templates/.
|
||||
required: true
|
||||
example: "www/custom.css"
|
||||
selector:
|
||||
text:
|
||||
content:
|
||||
name: Content
|
||||
description: The content to write to the file.
|
||||
required: true
|
||||
example: ".card { background: #333; }"
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
overwrite:
|
||||
name: Overwrite
|
||||
description: Whether to overwrite if the file already exists. Default is false.
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
create_dirs:
|
||||
name: Create Directories
|
||||
description: Whether to create parent directories if they don't exist. Default is true.
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
delete_file:
|
||||
name: Delete File
|
||||
description: Delete a file from allowed directories (www/, themes/, custom_templates/).
|
||||
fields:
|
||||
path:
|
||||
name: Path
|
||||
description: >-
|
||||
Relative path from config directory. Must be in www/, themes/, or
|
||||
custom_templates/.
|
||||
required: true
|
||||
example: "www/old-file.css"
|
||||
selector:
|
||||
text:
|
||||
|
||||
get_caller_token:
|
||||
name: Get Caller Token (Internal)
|
||||
description: >-
|
||||
Internal bootstrap service used by the ha-mcp server. Returns the auth
|
||||
token that the other ha_mcp_tools.* services require in their
|
||||
`_ha_mcp_token` field. The token is generated on first integration setup
|
||||
and persisted to .storage. Not intended for direct invocation from
|
||||
automations or scripts.
|
||||
fields: {}
|
||||
|
||||
get_allowed_paths:
|
||||
name: Get Allowed Filesystem Paths (Internal)
|
||||
description: >-
|
||||
Internal service used by the ha-mcp server settings UI. Returns the
|
||||
user-configurable extra read/write directories, the built-in allowlists,
|
||||
and the non-overridable deny floor. Restricted to the ha-mcp server
|
||||
(requires the `_ha_mcp_token`) and admin auth. Not intended for direct
|
||||
invocation from automations or scripts.
|
||||
fields: {}
|
||||
|
||||
set_allowed_paths:
|
||||
name: Set Allowed Filesystem Paths (Internal)
|
||||
description: >-
|
||||
Internal service used by the ha-mcp server settings UI. Replaces the
|
||||
user-configurable extra read/write directories (each granted both read and
|
||||
write). Entries that use path traversal, are absolute, escape the config
|
||||
directory, or hit the non-overridable deny floor (e.g. .storage) are
|
||||
dropped. Restricted to the ha-mcp server (requires the `_ha_mcp_token`) and
|
||||
admin auth.
|
||||
fields:
|
||||
paths:
|
||||
name: Paths
|
||||
description: >-
|
||||
Directories relative to the config directory, each granted read and
|
||||
write (e.g. "pyscript", "python_scripts"). Replaces the current list.
|
||||
required: false
|
||||
example: '["pyscript", "python_scripts"]'
|
||||
selector:
|
||||
object:
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "HA-MCP Custom Component",
|
||||
"description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. **HA MCP Tools** adds the privileged file and YAML editing services, which are only needed if you turn on ha-mcp's opt-in file/YAML tools; you can add it later at any time.",
|
||||
"menu_options": {
|
||||
"server": "HA-MCP Server (recommended)",
|
||||
"tools": "HA MCP Tools (optional file & YAML services)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "HA MCP Tools",
|
||||
"description": "Sets up the privileged file and YAML configuration services. Only needed if you enable ha-mcp's opt-in file/YAML editing tools (feature flags, off by default) - this applies to every server type, including the in-process HA-MCP Server. You can add or remove this entry at any time."
|
||||
},
|
||||
"server": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This entry is already set up.",
|
||||
"unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA MCP Tools entry with an external ha-mcp server running as an add-on or Docker container."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"no_options": "The HA MCP Tools services entry has no options to configure."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}",
|
||||
"data": {
|
||||
"channel": "Release channel",
|
||||
"auto_update": "Automatic server updates",
|
||||
"server_port": "MCP server listening port",
|
||||
"bind_host": "Network access",
|
||||
"webhook_auth": "Authentication mode",
|
||||
"pip_spec": "Developer: ha-mcp package override",
|
||||
"server_url": "Home Assistant URL (advanced)",
|
||||
"external_url": "External URL (optional)",
|
||||
"webhook_id_override": "Custom webhook secret (optional)",
|
||||
"secret_path_override": "Custom direct-access path (optional)",
|
||||
"regenerate_secrets": "Regenerate connect secrets now",
|
||||
"enable_webhook": "Remote access via webhook",
|
||||
"enable_llm_api": "Conversation-agent LLM API",
|
||||
"llm_api_exposure": "Conversation-agent tool exposure",
|
||||
"enable_startup_notification": "Startup notification",
|
||||
"enable_sidebar_panel": "Sidebar settings panel"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "Stable installs the latest stable release; Development installs the newest development build. When automatic updates are on, a reload or restart, plus a periodic check, install the newest build of the selected channel. A developer package override below takes precedence and disables automatic updates.",
|
||||
"auto_update": "When on, the newest release of the selected channel is installed automatically - on a reload or restart, and via a periodic check. When off, the server stays on the version currently installed until you turn this back on. This controls the ha-mcp server package only; updates to the HA-MCP Custom Component itself still come through HACS.",
|
||||
"server_port": "The port this server listens on. The ha-mcp add-on uses 9583, so this defaults to 9584 to let both run side by side - if you don't run the add-on, any free port works.",
|
||||
"bind_host": "Who can connect to the MCP server port directly. The default matches the add-on: reachable on your local network, with the secret path as the credential. Choose loopback to allow only connections from the Home Assistant machine itself - the webhook URL and the sidebar panel work either way.",
|
||||
"webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth).",
|
||||
"pip_spec": "Leave empty. Only for testing a specific ha-mcp build (for example a pre-release pin); overrides the release channel and disables automatic updates until cleared.",
|
||||
"server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave the default unless you know you need a different route.",
|
||||
"external_url": "Shown as the primary connect URL - use this when Home Assistant sits behind your own domain or reverse proxy. Enter the full base address including the scheme. It must point directly at Home Assistant - opening it in a browser should reach your HA login page - and must not contain a port such as :8123 (or any other port), or remote MCP clients won't be able to reach it. Leave empty to use Nabu Casa / the local address automatically.",
|
||||
"webhook_id_override": "Replaces the random webhook secret in the connect URL (/api/webhook/...). The URL is the credential - use a long, hard-to-guess value. Leave empty to keep the current one.",
|
||||
"secret_path_override": "Replaces the random path used for direct access on the server port. Same rule: the path is the credential. Leave empty to keep the current one.",
|
||||
"regenerate_secrets": "One-time action: mints a fresh random webhook secret and direct-access path, invalidating the old connect URLs immediately. Also clears the two override fields above.",
|
||||
"enable_webhook": "Turn off for local-only mode: the Home Assistant webhook is not registered at all, so nothing - including Nabu Casa - can reach the server through Home Assistant. The direct server port and the sidebar panel keep working.",
|
||||
"enable_llm_api": "Offer the full toolset to Home Assistant conversation agents (OpenAI, Google, Ollama, ...): while enabled, agents can select 'HA-MCP Server' under Control Home Assistant and drive the tools from Assist chat and voice. Enabling only makes it selectable - nothing is exposed until you pick it on an agent. Usage guide: {llm_api_docs_url}",
|
||||
"llm_api_exposure": "Shape of the toolset offered to conversation agents. Tool search (default) keeps the agent's context small: a compact API with pinned tools plus search/execute meta-tools. Full catalog lists every exposed tool directly - better for large-context models. Both registers the two side by side so each agent picks its own under Control Home Assistant. Per-tool exposure is managed in the HA-MCP settings panel; details: {llm_api_docs_url}",
|
||||
"enable_startup_notification": "Show a notification each time the server starts, pointing at the admin-only settings surfaces. Turn off to start silently - the connect URLs still appear in the Home Assistant log.",
|
||||
"enable_sidebar_panel": "Show the HA-MCP settings panel in the sidebar (administrators only). Turn off to remove the sidebar entry - server options stay available on this screen."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "The HA-MCP in-process server failed to start",
|
||||
"description": "The HA-MCP in-process server could not be started inside Home Assistant:\n\n{detail}\n\nCheck the Home Assistant logs, then reload the integration (or fix the underlying problem and reload) to retry."
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "The HA-MCP in-process server package could not be installed",
|
||||
"description": "Installing the ha-mcp package for the in-process server failed:\n\n{detail}\n\nResolve the compatibility or installation problem described above, then reload the integration to retry."
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Update the HA-MCP Custom Component via HACS",
|
||||
"description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "HA-MCP server update waiting for a component update",
|
||||
"description": "ha-mcp server {latest} is available, but that release also updated the HA-MCP Custom Component (to {shipped}; you are running {running}). To avoid starting a server version the running component has never been tested with, the automatic server update is on hold until the component is updated.\n\nUpdate the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet), then restart Home Assistant - the server update installs automatically afterwards. To install the server update anyway, press Install on the HA-MCP server update entity."
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "Component installed from the legacy repository",
|
||||
"description": "HACS is tracking the main ha-mcp server repository for this component, so HACS shows the server's version numbers (7.x) and the server's release notes here instead of the component's own (1.x). Updates keep working, but stay mislabeled this way. To fix: remove this repository from HACS (your integration settings and config entries are kept), add homeassistant-ai/ha-mcp-integration as a custom repository, reinstall the component from it, and restart Home Assistant."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "Stable (recommended)",
|
||||
"dev": "Development (latest build)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "Secret webhook URL (default)",
|
||||
"ha_auth": "Sign in with Home Assistant (OAuth)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "Tool search (compact, default)",
|
||||
"full": "Full catalog",
|
||||
"both": "Both (choose per agent)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "Update"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "HA-MCP Custom Component",
|
||||
"description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. **HA MCP Tools** adds the privileged file and YAML editing services, which are only needed if you turn on ha-mcp's opt-in file/YAML tools; you can add it later at any time.",
|
||||
"menu_options": {
|
||||
"server": "HA-MCP Server (recommended)",
|
||||
"tools": "HA MCP Tools (optional file & YAML services)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "HA MCP Tools",
|
||||
"description": "Sets up the privileged file and YAML configuration services. Only needed if you enable ha-mcp's opt-in file/YAML editing tools (feature flags, off by default) - this applies to every server type, including the in-process HA-MCP Server. You can add or remove this entry at any time."
|
||||
},
|
||||
"server": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This entry is already set up.",
|
||||
"unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA MCP Tools entry with an external ha-mcp server running as an add-on or Docker container."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"no_options": "The HA MCP Tools services entry has no options to configure."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}",
|
||||
"data": {
|
||||
"channel": "Release channel",
|
||||
"auto_update": "Automatic server updates",
|
||||
"server_port": "MCP server listening port",
|
||||
"bind_host": "Network access",
|
||||
"webhook_auth": "Authentication mode",
|
||||
"pip_spec": "Developer: ha-mcp package override",
|
||||
"server_url": "Home Assistant URL (advanced)",
|
||||
"external_url": "External URL (optional)",
|
||||
"webhook_id_override": "Custom webhook secret (optional)",
|
||||
"secret_path_override": "Custom direct-access path (optional)",
|
||||
"regenerate_secrets": "Regenerate connect secrets now",
|
||||
"enable_webhook": "Remote access via webhook",
|
||||
"enable_llm_api": "Conversation-agent LLM API",
|
||||
"llm_api_exposure": "Conversation-agent tool exposure",
|
||||
"enable_startup_notification": "Startup notification",
|
||||
"enable_sidebar_panel": "Sidebar settings panel"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "Stable installs the latest stable release; Development installs the newest development build. When automatic updates are on, a reload or restart, plus a periodic check, install the newest build of the selected channel. A developer package override below takes precedence and disables automatic updates.",
|
||||
"auto_update": "When on, the newest release of the selected channel is installed automatically - on a reload or restart, and via a periodic check. When off, the server stays on the version currently installed until you turn this back on. This controls the ha-mcp server package only; updates to the HA-MCP Custom Component itself still come through HACS.",
|
||||
"server_port": "The port this server listens on. The ha-mcp add-on uses 9583, so this defaults to 9584 to let both run side by side - if you don't run the add-on, any free port works.",
|
||||
"bind_host": "Who can connect to the MCP server port directly. The default matches the add-on: reachable on your local network, with the secret path as the credential. Choose loopback to allow only connections from the Home Assistant machine itself - the webhook URL and the sidebar panel work either way.",
|
||||
"webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth).",
|
||||
"pip_spec": "Leave empty. Only for testing a specific ha-mcp build (for example a pre-release pin); overrides the release channel and disables automatic updates until cleared.",
|
||||
"server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave the default unless you know you need a different route.",
|
||||
"external_url": "Shown as the primary connect URL - use this when Home Assistant sits behind your own domain or reverse proxy. Enter the full base address including the scheme. It must point directly at Home Assistant - opening it in a browser should reach your HA login page - and must not contain a port such as :8123 (or any other port), or remote MCP clients won't be able to reach it. Leave empty to use Nabu Casa / the local address automatically.",
|
||||
"webhook_id_override": "Replaces the random webhook secret in the connect URL (/api/webhook/...). The URL is the credential - use a long, hard-to-guess value. Leave empty to keep the current one.",
|
||||
"secret_path_override": "Replaces the random path used for direct access on the server port. Same rule: the path is the credential. Leave empty to keep the current one.",
|
||||
"regenerate_secrets": "One-time action: mints a fresh random webhook secret and direct-access path, invalidating the old connect URLs immediately. Also clears the two override fields above.",
|
||||
"enable_webhook": "Turn off for local-only mode: the Home Assistant webhook is not registered at all, so nothing - including Nabu Casa - can reach the server through Home Assistant. The direct server port and the sidebar panel keep working.",
|
||||
"enable_llm_api": "Offer the full toolset to Home Assistant conversation agents (OpenAI, Google, Ollama, ...): while enabled, agents can select 'HA-MCP Server' under Control Home Assistant and drive the tools from Assist chat and voice. Enabling only makes it selectable - nothing is exposed until you pick it on an agent. Usage guide: {llm_api_docs_url}",
|
||||
"llm_api_exposure": "Shape of the toolset offered to conversation agents. Tool search (default) keeps the agent's context small: a compact API with pinned tools plus search/execute meta-tools. Full catalog lists every exposed tool directly - better for large-context models. Both registers the two side by side so each agent picks its own under Control Home Assistant. Per-tool exposure is managed in the HA-MCP settings panel; details: {llm_api_docs_url}",
|
||||
"enable_startup_notification": "Show a notification each time the server starts, pointing at the admin-only settings surfaces. Turn off to start silently - the connect URLs still appear in the Home Assistant log.",
|
||||
"enable_sidebar_panel": "Show the HA-MCP settings panel in the sidebar (administrators only). Turn off to remove the sidebar entry - server options stay available on this screen."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "The HA-MCP in-process server failed to start",
|
||||
"description": "The HA-MCP in-process server could not be started inside Home Assistant:\n\n{detail}\n\nCheck the Home Assistant logs, then reload the integration (or fix the underlying problem and reload) to retry."
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "The HA-MCP in-process server package could not be installed",
|
||||
"description": "Installing the ha-mcp package for the in-process server failed:\n\n{detail}\n\nResolve the compatibility or installation problem described above, then reload the integration to retry."
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "Update the HA-MCP Custom Component via HACS",
|
||||
"description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated."
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "HA-MCP server update waiting for a component update",
|
||||
"description": "ha-mcp server {latest} is available, but that release also updated the HA-MCP Custom Component (to {shipped}; you are running {running}). To avoid starting a server version the running component has never been tested with, the automatic server update is on hold until the component is updated.\n\nUpdate the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet), then restart Home Assistant - the server update installs automatically afterwards. To install the server update anyway, press Install on the HA-MCP server update entity."
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "Component installed from the legacy repository",
|
||||
"description": "HACS is tracking the main ha-mcp server repository for this component, so HACS shows the server's version numbers (7.x) and the server's release notes here instead of the component's own (1.x). Updates keep working, but stay mislabeled this way. To fix: remove this repository from HACS (your integration settings and config entries are kept), add homeassistant-ai/ha-mcp-integration as a custom repository, reinstall the component from it, and restart Home Assistant."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "Stable (recommended)",
|
||||
"dev": "Development (latest build)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "Secret webhook URL (default)",
|
||||
"ha_auth": "Sign in with Home Assistant (OAuth)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "Tool search (compact, default)",
|
||||
"full": "Full catalog",
|
||||
"both": "Both (choose per agent)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "Update"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
"""Admin-only "Open Web UI" access to the in-process server's settings page (#1527).
|
||||
|
||||
The in-process ha-mcp server serves its web settings UI on the loopback interface
|
||||
at ``http://127.0.0.1:<port><secret_path>/settings`` — unreachable from a browser
|
||||
and guarded only by the secret path. This module gives every install type the
|
||||
add-on's "Open Web UI" experience: an admin-only sidebar panel ("HA-MCP") that
|
||||
opens that settings UI through Home Assistant's own HTTP server, so it works over
|
||||
the Nabu Casa remote URL and never exposes the loopback secret path to the browser.
|
||||
|
||||
The sidebar entry is a built-in ``iframe`` panel — the panel type behind
|
||||
"webpage" dashboards — NOT a custom panel. Home Assistant itself renders the
|
||||
standard header (with the sidebar menu button) around our page, so navigation
|
||||
behaves exactly like every other dashboard. The previous custom panel painted a
|
||||
bare full-height iframe with no chrome, which left iOS companion-app users with
|
||||
no way back to the HA UI: iOS has no system back button, edge swipes land inside
|
||||
the iframe where the frontend cannot see them, and the app restores the trapped
|
||||
route on every relaunch (#1795).
|
||||
|
||||
Auth model — an iframe panel navigation is a browser GET that carries no
|
||||
``Authorization`` header, so Home Assistant's normal ``requires_auth`` cannot gate
|
||||
it. HA's signed-path helper (:func:`homeassistant.components.http.async_sign_path`)
|
||||
is also unusable: a signature binds ONE exact path + query string, but the settings
|
||||
app issues relative ``./api/settings/*`` fetches that drop the query — each would
|
||||
land on a different, unsigned path and 401. Instead:
|
||||
|
||||
1. The panel's iframe loads :class:`_BootView`, a tiny same-origin bootstrap
|
||||
page (public glue, no secrets). Being same-origin with the authenticated
|
||||
frontend, it reads the logged-in user's access token from the parent frame's
|
||||
``home-assistant`` root element and POSTs it to the session endpoint below.
|
||||
2. :class:`_SessionView` (``requires_auth=True``) authenticates that token the
|
||||
normal way, refuses non-admins, and returns a short-lived HttpOnly,
|
||||
SameSite=Strict session cookie scoped to the proxy path.
|
||||
3. The boot page then embeds ``…/ui/app/settings`` in an inner iframe. The
|
||||
browser attaches the cookie to every same-origin request under the proxy
|
||||
path — including the settings app's relative sub-fetches — so the whole app
|
||||
works unchanged.
|
||||
4. :class:`_ProxyView` (``requires_auth=False`` because the iframe cannot send a
|
||||
bearer) validates that cookie against a live admin user on every request and
|
||||
forwards to the loopback settings server.
|
||||
|
||||
The proxy reuses the server's loopback forwarding config + aiohttp session
|
||||
(``hass.data[DOMAIN][DATA_WEBHOOK]`` — stored whenever the server is running,
|
||||
even when the public webhook endpoint is disabled), so it is available exactly
|
||||
while the server is running and returns 503 otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DATA_WEBHOOK, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Sidebar panel identity. The url_path is the frontend route (…/ha-mcp).
|
||||
# "HA-MCP" (not "MCP Server") avoids confusion with HA's official MCP Server
|
||||
# integration.
|
||||
PANEL_URL_PATH = "ha-mcp"
|
||||
PANEL_TITLE = "HA-MCP"
|
||||
PANEL_ICON = "mdi:robot-happy-outline"
|
||||
|
||||
# HTTP surface, all under one base so the session cookie can be tightly scoped.
|
||||
_UI_BASE = "/api/ha_mcp_tools/ui"
|
||||
_BOOT_URL = f"{_UI_BASE}/boot"
|
||||
_SESSION_URL = f"{_UI_BASE}/session"
|
||||
_APP_PREFIX = f"{_UI_BASE}/app/"
|
||||
_PROXY_URL = f"{_UI_BASE}/app/{{path:.*}}"
|
||||
|
||||
# Session cookie. HttpOnly so page JS can never read it; SameSite=Strict so it
|
||||
# rides only same-site requests (the iframe is same-origin with the frontend);
|
||||
# path-scoped to the proxy so it is never sent to the boot/session endpoints.
|
||||
_COOKIE_NAME = "ha_mcp_tools_ui_session"
|
||||
_COOKIE_PATH = f"{_UI_BASE}/app"
|
||||
|
||||
# Session lifetime. Short by design; the panel re-mints well within it while open.
|
||||
_SESSION_TTL_SECONDS = 8 * 60 * 60
|
||||
|
||||
# Top-level hass.data keys. Both must survive config-entry teardown: aiohttp
|
||||
# cannot unregister a bound view, so the views (and the sessions they validate)
|
||||
# outlive a reload / re-enable of the entry.
|
||||
_VIEWS_REGISTERED_KEY = "ha_mcp_tools_ui_views_registered"
|
||||
_SESSIONS_KEY = "ha_mcp_tools_ui_sessions"
|
||||
|
||||
# Request headers never forwarded to the loopback server. Hop-by-hop plus the
|
||||
# browser's cookie/authorization (the loopback server has no auth on the secret
|
||||
# path and must not receive the session cookie or the frontend bearer).
|
||||
_STRIPPED_REQUEST_HEADERS = frozenset(
|
||||
{
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"cookie",
|
||||
"authorization",
|
||||
}
|
||||
)
|
||||
|
||||
# Response headers recomputed by aiohttp on the way out, or invalid once the body
|
||||
# has been transparently decompressed by ``resp.read()``. Everything else
|
||||
# (Content-Type, Cache-Control, …) passes through so the settings app behaves
|
||||
# exactly as when reached directly.
|
||||
_STRIPPED_RESPONSE_HEADERS = frozenset(
|
||||
{
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
"keep-alive",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session store (server-side; no secret ever placed in a URL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sessions(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
|
||||
"""Return the token → ``{user_id, expires}`` store, creating it once."""
|
||||
store = hass.data.get(_SESSIONS_KEY)
|
||||
if not isinstance(store, dict):
|
||||
store = {}
|
||||
hass.data[_SESSIONS_KEY] = store
|
||||
return store
|
||||
|
||||
|
||||
def _prune_expired(store: dict[str, dict[str, Any]], now: float) -> None:
|
||||
"""Drop expired sessions so the store cannot grow without bound."""
|
||||
for token in [t for t, s in store.items() if s["expires"] <= now]:
|
||||
del store[token]
|
||||
|
||||
|
||||
def _mint_session(hass: HomeAssistant, user_id: str) -> str:
|
||||
"""Create and store a new session token for ``user_id``; return the token."""
|
||||
store = _sessions(hass)
|
||||
now = time.monotonic()
|
||||
_prune_expired(store, now)
|
||||
token = secrets.token_urlsafe(32)
|
||||
store[token] = {"user_id": user_id, "expires": now + _SESSION_TTL_SECONDS}
|
||||
return token
|
||||
|
||||
|
||||
async def _session_user_is_admin(hass: HomeAssistant, token: str | None) -> bool:
|
||||
"""Return True iff ``token`` maps to a live, still-admin user session.
|
||||
|
||||
Re-checks the user's admin flag on every request so revoking admin (or the
|
||||
user) takes effect immediately, not only when the session expires. A stale or
|
||||
demoted session is dropped so it cannot be retried.
|
||||
"""
|
||||
if not token:
|
||||
return False
|
||||
store = _sessions(hass)
|
||||
now = time.monotonic()
|
||||
_prune_expired(store, now)
|
||||
session = store.get(token)
|
||||
if session is None:
|
||||
return False
|
||||
user = await hass.auth.async_get_user(session["user_id"])
|
||||
if (
|
||||
user is None
|
||||
or getattr(user, "system_generated", False)
|
||||
or not getattr(user, "is_active", False)
|
||||
or not getattr(user, "is_admin", False)
|
||||
):
|
||||
# Same acceptance bar as the ha_auth webhook gate (review finding:
|
||||
# the two admin gates must not drift): active, human, administrator.
|
||||
store.pop(token, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Views
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _BootView(HomeAssistantView):
|
||||
"""Serve the bootstrap page the iframe panel embeds (public glue, no secrets).
|
||||
|
||||
``requires_auth`` is False because the panel's iframe loads this with a plain
|
||||
GET that cannot attach a bearer. The page contains only the bootstrap that
|
||||
mints a session (with the token it reads from the parent frontend frame) and
|
||||
embeds the proxied settings app.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = False
|
||||
url = _BOOT_URL
|
||||
name = "ha_mcp_tools:ui:boot"
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Return the bootstrap HTML page."""
|
||||
return web.Response(
|
||||
body=_BOOT_HTML.encode("utf-8"),
|
||||
content_type="text/html",
|
||||
charset="utf-8",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
class _SessionView(HomeAssistantView):
|
||||
"""Mint a short-lived session cookie for an authenticated admin user.
|
||||
|
||||
``requires_auth`` is True, so Home Assistant validates the frontend's bearer
|
||||
before this runs. The extra admin check refuses non-admins (the panel is
|
||||
admin-only, and the settings UI can change privileged server settings).
|
||||
"""
|
||||
|
||||
requires_auth = True
|
||||
cors_allowed = False
|
||||
url = _SESSION_URL
|
||||
name = "ha_mcp_tools:ui:session"
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
"""Issue the session cookie, or 403 for a non-admin caller."""
|
||||
user = request.get("hass_user")
|
||||
if user is None or not getattr(user, "is_admin", False):
|
||||
return web.json_response({"error": "admin_required"}, status=403)
|
||||
|
||||
token = _mint_session(request.app["hass"], user.id)
|
||||
response = web.json_response({"ttl": _SESSION_TTL_SECONDS})
|
||||
response.set_cookie(
|
||||
_COOKIE_NAME,
|
||||
token,
|
||||
max_age=_SESSION_TTL_SECONDS,
|
||||
path=_COOKIE_PATH,
|
||||
httponly=True,
|
||||
samesite="Strict",
|
||||
secure=_request_is_https(request),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class _ProxyView(HomeAssistantView):
|
||||
"""Forward settings-UI traffic to the loopback server for a valid session.
|
||||
|
||||
``requires_auth`` is False because the iframe (and its relative sub-fetches)
|
||||
cannot send a bearer; the session cookie minted by :class:`_SessionView` is
|
||||
the credential and is re-validated against a live admin user on every request.
|
||||
Returns 503 while the server is not running and 401 without a valid session.
|
||||
"""
|
||||
|
||||
requires_auth = False
|
||||
cors_allowed = False
|
||||
url = _PROXY_URL
|
||||
name = "ha_mcp_tools:ui:proxy"
|
||||
|
||||
async def get(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||
"""Proxy a GET (the settings page and read endpoints)."""
|
||||
return await self._forward(request, path)
|
||||
|
||||
async def post(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||
"""Proxy a POST (save endpoints)."""
|
||||
return await self._forward(request, path)
|
||||
|
||||
async def put(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||
"""Proxy a PUT (policy-config writes)."""
|
||||
return await self._forward(request, path)
|
||||
|
||||
async def delete(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||
"""Proxy a DELETE (backup deletion)."""
|
||||
return await self._forward(request, path)
|
||||
|
||||
async def _forward(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||
"""Validate the session, then forward to the loopback settings server."""
|
||||
hass: HomeAssistant = request.app["hass"]
|
||||
|
||||
if not await _session_user_is_admin(hass, request.cookies.get(_COOKIE_NAME)):
|
||||
return web.Response(status=401, text="Unauthorized")
|
||||
|
||||
# Defense in depth: never let a crafted path escape the secret-path
|
||||
# prefix on the loopback server (the caller is already an admin, so this
|
||||
# only blocks confusing requests, but it keeps the target well-formed).
|
||||
if any(segment == ".." for segment in path.split("/")):
|
||||
return web.Response(status=400, text="Bad request")
|
||||
|
||||
cfg = _webhook_cfg(hass)
|
||||
if cfg is None:
|
||||
return web.Response(status=503, text="The MCP server is not running")
|
||||
|
||||
target = f"{cfg['target_url']}/{path}"
|
||||
if request.query_string:
|
||||
target = f"{target}?{request.query_string}"
|
||||
session: aiohttp.ClientSession = cfg["session"]
|
||||
|
||||
body = await request.read()
|
||||
forward_headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in _STRIPPED_REQUEST_HEADERS
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.request(
|
||||
method=request.method,
|
||||
url=target,
|
||||
headers=forward_headers,
|
||||
data=body if body else None,
|
||||
) as upstream:
|
||||
return await _relay_response(request, upstream)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("HA-MCP settings proxy: upstream request failed: %s", err)
|
||||
return web.Response(status=502, text="MCP settings server unavailable")
|
||||
except Exception as err:
|
||||
_LOGGER.exception("HA-MCP settings proxy: unexpected error: %s", err)
|
||||
return web.Response(status=500, text="MCP settings server error")
|
||||
|
||||
|
||||
async def _relay_response(
|
||||
request: web.Request, upstream: aiohttp.ClientResponse
|
||||
) -> web.StreamResponse:
|
||||
"""Relay the loopback response, streaming when it is an event stream.
|
||||
|
||||
The loopback server is our own trusted process, so — unlike the MCP webhook —
|
||||
the Content-Type is passed through unchanged (the settings page is text/html,
|
||||
the API endpoints are JSON): coercing it would break the page.
|
||||
"""
|
||||
content_type = upstream.headers.get("Content-Type", "")
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in upstream.headers.items()
|
||||
if key.lower() not in _STRIPPED_RESPONSE_HEADERS
|
||||
}
|
||||
|
||||
if "text/event-stream" in content_type:
|
||||
headers["Cache-Control"] = "no-cache, no-transform"
|
||||
headers["X-Accel-Buffering"] = "no"
|
||||
response = web.StreamResponse(status=upstream.status, headers=headers)
|
||||
await response.prepare(request)
|
||||
try:
|
||||
async for chunk in upstream.content.iter_any():
|
||||
await response.write(chunk)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("HA-MCP settings proxy: upstream dropped mid-stream: %s", err)
|
||||
with _suppress_connection_reset():
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
return web.Response(
|
||||
status=upstream.status, body=await upstream.read(), headers=headers
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration / teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def async_register_ui_panel(hass: HomeAssistant) -> None:
|
||||
"""Register the settings-UI proxy views (once) and the sidebar panel.
|
||||
|
||||
Called from the server entry's setup. The views resolve the running server
|
||||
from ``hass.data`` per request, so they are bound once per HA session and
|
||||
reused across reloads; the panel is (re)added here and removed on unload.
|
||||
Any failure is logged and swallowed — a frontend hiccup must never block the
|
||||
config entry from loading.
|
||||
"""
|
||||
try:
|
||||
_register_views(hass)
|
||||
await _register_panel(hass)
|
||||
except Exception:
|
||||
_LOGGER.exception("HA-MCP: failed to register the settings-UI panel")
|
||||
|
||||
|
||||
def async_unregister_ui_panel(hass: HomeAssistant) -> None:
|
||||
"""Remove the sidebar panel on entry unload (the views stay bound).
|
||||
|
||||
aiohttp cannot unregister the views; they return 503 once the server is no
|
||||
longer running, so removing the sidebar entry is enough to reflect the
|
||||
paused/removed state.
|
||||
"""
|
||||
from homeassistant.components.frontend import async_remove_panel
|
||||
|
||||
with _suppress_all():
|
||||
async_remove_panel(hass, PANEL_URL_PATH, warn_if_unknown=False)
|
||||
|
||||
|
||||
def _register_views(hass: HomeAssistant) -> None:
|
||||
"""Bind the boot / session / proxy views at most once per HA session."""
|
||||
if hass.data.get(_VIEWS_REGISTERED_KEY):
|
||||
return
|
||||
hass.http.register_view(_BootView())
|
||||
hass.http.register_view(_SessionView())
|
||||
hass.http.register_view(_ProxyView())
|
||||
hass.data[_VIEWS_REGISTERED_KEY] = True
|
||||
|
||||
|
||||
async def _register_panel(hass: HomeAssistant) -> None:
|
||||
"""Add the admin-only sidebar panel if it is not already present.
|
||||
|
||||
Registered as a built-in ``iframe`` panel (the "webpage dashboard" panel
|
||||
type): Home Assistant renders its standard header around the page, so the
|
||||
panel can never trap navigation the way a chrome-less custom panel did on
|
||||
iOS (#1795).
|
||||
"""
|
||||
from homeassistant.components.frontend import (
|
||||
async_panel_exists,
|
||||
async_register_built_in_panel,
|
||||
)
|
||||
|
||||
if async_panel_exists(hass, PANEL_URL_PATH):
|
||||
return
|
||||
cfg = panel_config()
|
||||
async_register_built_in_panel(hass, cfg.pop("component_name"), **cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
"""Return the running server's forwarding config, or None when it is down."""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if not isinstance(domain_data, dict):
|
||||
return None
|
||||
cfg = domain_data.get(DATA_WEBHOOK)
|
||||
return cfg if isinstance(cfg, dict) else None
|
||||
|
||||
|
||||
def _request_is_https(request: web.Request) -> bool:
|
||||
"""Return True when the request reached HA over HTTPS (honoring the proxy)."""
|
||||
forwarded = request.headers.get("X-Forwarded-Proto")
|
||||
return bool((forwarded or request.scheme) == "https")
|
||||
|
||||
|
||||
class _suppress_connection_reset:
|
||||
"""Swallow a ConnectionResetError from a client that closed mid-stream."""
|
||||
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
||||
return exc_type is not None and issubclass(exc_type, ConnectionResetError)
|
||||
|
||||
|
||||
class _suppress_all:
|
||||
"""Swallow any Exception from best-effort teardown, logged at WARNING."""
|
||||
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
||||
if exc_type is None or not issubclass(exc_type, Exception):
|
||||
return False # never swallow KeyboardInterrupt/SystemExit
|
||||
_LOGGER.warning("HA-MCP: settings-UI panel teardown error", exc_info=exc)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap page (embedded by the built-in iframe panel)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Plain same-origin page (no Lit / HA-frontend imports) so it never couples to a
|
||||
# specific frontend build. Home Assistant's own iframe panel draws the standard
|
||||
# header around it; this page only mints the session and embeds the proxied
|
||||
# settings app. The script is a separate string so the node syntax test can
|
||||
# parse it alone. Deliberately NOT registered in _js_harness._PY_RENDERERS
|
||||
# (importing this module needs Home Assistant installed, which would break the
|
||||
# harness for every surface); coverage = the node --check syntax test plus the
|
||||
# Python-side session/proxy tests in test_ui_panel.py.
|
||||
|
||||
_BOOT_JS = f"""
|
||||
const SESSION_URL = {_SESSION_URL!r};
|
||||
const APP_URL = {_APP_PREFIX!r} + "settings";
|
||||
// Re-mint at half the cookie lifetime so an open panel never expires mid-use.
|
||||
const REFRESH_MS = {_SESSION_TTL_SECONDS // 2} * 1000;
|
||||
// While the frontend is still booting (a cold start straight into this panel),
|
||||
// the parent frame has no token yet -- poll gently until it does (local reads,
|
||||
// no network). After TOKEN_HINT_AFTER misses, surface a hint but keep polling.
|
||||
const TOKEN_RETRY_MS = 1000;
|
||||
const TOKEN_HINT_AFTER = 20;
|
||||
// Transient failures (network blip, server starting/restarting) retry on
|
||||
// their own. Auth refusals (401/403) never auto-retry: every rejected bearer
|
||||
// counts as a failed login for http.ban, and a retry loop got users IP-banned
|
||||
// from their own instance (#1802).
|
||||
const RETRY_MS = 5000;
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
const msg = document.querySelector(".msg");
|
||||
const frame = document.querySelector("iframe");
|
||||
let timer = null;
|
||||
let busy = false;
|
||||
let tokenMisses = 0;
|
||||
let authDead = false;
|
||||
|
||||
function showMessage(text, isError) {{
|
||||
frame.classList.add("hidden");
|
||||
msg.classList.remove("hidden");
|
||||
// Failure messages announce assertively (style guide: status regions switch
|
||||
// to role=alert on the failure path); benign progress stays polite.
|
||||
msg.setAttribute("role", isError ? "alert" : "status");
|
||||
msg.setAttribute("aria-live", isError ? "assertive" : "polite");
|
||||
msg.textContent = text;
|
||||
}}
|
||||
|
||||
function transientFailure(text) {{
|
||||
// Keep an already-working app visible through a transient blip (the iframe
|
||||
// holds state); only surface the message while nothing is showing yet.
|
||||
if (!timer) {{
|
||||
showMessage(text, true);
|
||||
}}
|
||||
setTimeout(mint, RETRY_MS);
|
||||
}}
|
||||
|
||||
function fetchWithTimeout(url, options) {{
|
||||
// A stalled (never-settling) fetch would wedge `busy` and silently stop all
|
||||
// future re-mints; a timeout resolves it into the retry path instead.
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
return fetch(url, Object.assign({{ signal: controller.signal }}, options)).finally(
|
||||
() => clearTimeout(t)
|
||||
);
|
||||
}}
|
||||
|
||||
async function token() {{
|
||||
// Same-origin parent = the authenticated HA frontend. Its root element owns
|
||||
// the live `hass` object the frontend keeps fresh; its auth.accessToken is
|
||||
// the same bearer the frontend itself uses. Refresh an expired token before
|
||||
// use -- POSTing a stale bearer counts as a failed login for http.ban
|
||||
// (#1802). A failed refresh means the sign-in itself is dead: mark it
|
||||
// terminal rather than looping.
|
||||
try {{
|
||||
if (window.parent === window) return null;
|
||||
const root = window.parent.document.querySelector("home-assistant");
|
||||
const auth = root && root.hass && root.hass.auth;
|
||||
if (!auth) return null;
|
||||
if (auth.expired && typeof auth.refreshAccessToken === "function") {{
|
||||
try {{
|
||||
await auth.refreshAccessToken();
|
||||
}} catch (err) {{
|
||||
authDead = true;
|
||||
return null;
|
||||
}}
|
||||
}}
|
||||
return auth.accessToken || (auth.data && auth.data.access_token) || null;
|
||||
}} catch (err) {{
|
||||
return null; // cross-origin parent: not embedded in the HA frontend
|
||||
}}
|
||||
}}
|
||||
|
||||
async function mint() {{
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {{
|
||||
const bearer = await token();
|
||||
if (!bearer) {{
|
||||
if (authDead) {{
|
||||
showMessage(
|
||||
"The Home Assistant sign-in has expired. Reload the page to try again.",
|
||||
true
|
||||
);
|
||||
return;
|
||||
}}
|
||||
if (window.parent === window) {{
|
||||
showMessage("Open this page from the HA-MCP entry in the Home Assistant sidebar.");
|
||||
return;
|
||||
}}
|
||||
tokenMisses += 1;
|
||||
if (tokenMisses === TOKEN_HINT_AFTER) {{
|
||||
showMessage(
|
||||
"Still waiting for the Home Assistant sign-in. If this page is not " +
|
||||
"inside the Home Assistant frontend, open it from the HA-MCP " +
|
||||
"sidebar entry."
|
||||
);
|
||||
}}
|
||||
setTimeout(mint, TOKEN_RETRY_MS);
|
||||
return;
|
||||
}}
|
||||
tokenMisses = 0;
|
||||
let resp;
|
||||
try {{
|
||||
resp = await fetchWithTimeout(SESSION_URL, {{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {{ Authorization: "Bearer " + bearer }},
|
||||
}});
|
||||
}} catch (err) {{
|
||||
transientFailure("Could not reach Home Assistant to open the settings UI.");
|
||||
return;
|
||||
}}
|
||||
if (resp.status === 401) {{
|
||||
// Never loop on a rejected bearer -- see the RETRY_MS note (#1802).
|
||||
showMessage(
|
||||
"Home Assistant rejected the sign-in token. Reload the page to try again.",
|
||||
true
|
||||
);
|
||||
return;
|
||||
}}
|
||||
if (resp.status === 403) {{
|
||||
showMessage("The HA-MCP settings UI is available to administrators only.", true);
|
||||
return;
|
||||
}}
|
||||
if (!resp.ok) {{
|
||||
transientFailure("Could not open the settings UI (HTTP " + resp.status + ").");
|
||||
return;
|
||||
}}
|
||||
await showApp();
|
||||
}} finally {{
|
||||
busy = false;
|
||||
}}
|
||||
}}
|
||||
|
||||
async function showApp() {{
|
||||
// Probe the proxy so a not-yet-running server shows a friendly message
|
||||
// instead of a raw 503 page inside the iframe.
|
||||
let probe;
|
||||
try {{
|
||||
probe = await fetchWithTimeout(APP_URL, {{ credentials: "same-origin" }});
|
||||
}} catch (err) {{
|
||||
transientFailure("Could not reach Home Assistant to load the settings UI.");
|
||||
return;
|
||||
}}
|
||||
if (probe.status === 503) {{
|
||||
if (!timer) {{
|
||||
showMessage(
|
||||
"The in-process MCP server is starting or is not running yet. " +
|
||||
"This view will refresh automatically."
|
||||
);
|
||||
}}
|
||||
setTimeout(mint, RETRY_MS);
|
||||
return;
|
||||
}}
|
||||
if (!probe.ok) {{
|
||||
transientFailure("The settings UI returned HTTP " + probe.status + ".");
|
||||
return;
|
||||
}}
|
||||
if (frame.getAttribute("src") !== APP_URL) {{
|
||||
frame.setAttribute("src", APP_URL);
|
||||
}}
|
||||
msg.classList.add("hidden");
|
||||
frame.classList.remove("hidden");
|
||||
if (!timer) {{
|
||||
timer = setInterval(mint, REFRESH_MS);
|
||||
}}
|
||||
}}
|
||||
|
||||
mint();
|
||||
"""
|
||||
|
||||
_BOOT_HTML = f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HA-MCP settings</title>
|
||||
<style>
|
||||
html, body {{ height: 100%; margin: 0; background: #fafafa; }}
|
||||
main {{ height: 100%; outline: none; }}
|
||||
iframe {{ width: 100%; height: 100%; border: 0; display: block; }}
|
||||
.msg {{
|
||||
padding: 24px; max-width: 640px; margin: 0 auto; box-sizing: border-box;
|
||||
font-family: Roboto, sans-serif; color: #212121;
|
||||
}}
|
||||
.hidden {{ display: none; }}
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
html, body {{ background: #111111; }}
|
||||
.msg {{ color: #e1e1e1; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="main-content" tabindex="-1">
|
||||
<div class="msg" role="status" aria-live="polite">Loading the HA-MCP settings UI…</div>
|
||||
<iframe class="hidden" title="HA-MCP settings"></iframe>
|
||||
</main>
|
||||
<script>
|
||||
{_BOOT_JS}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def render_boot_script() -> str:
|
||||
"""Return the boot-page script source (used by the JS-parse tests)."""
|
||||
return _BOOT_JS
|
||||
|
||||
|
||||
def render_boot_page() -> str:
|
||||
"""Return the boot-page HTML (used by the tests)."""
|
||||
return _BOOT_HTML
|
||||
|
||||
|
||||
def panel_config() -> ConfigType:
|
||||
"""Return the sidebar-panel registration parameters (registration + tests)."""
|
||||
return {
|
||||
"component_name": "iframe",
|
||||
"frontend_url_path": PANEL_URL_PATH,
|
||||
"sidebar_title": PANEL_TITLE,
|
||||
"sidebar_icon": PANEL_ICON,
|
||||
"config": {"url": _BOOT_URL},
|
||||
"require_admin": True,
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Update platform for the in-process server package (issue #1760).
|
||||
|
||||
Exposes one ``update`` entity per "server" config entry for the ha-mcp server
|
||||
package it runs in-process, backed by :class:`~.coordinator.ServerVersionCoordinator`.
|
||||
The entity stays populated whether or not automatic updates are on - see the
|
||||
coordinator's docstring for why.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
DATA_BRINGUP_TASK,
|
||||
DATA_PENDING_INSTALL_VERSION,
|
||||
DATA_UPDATE_COORDINATOR,
|
||||
DEFAULT_AUTO_UPDATE,
|
||||
DIST_NAME_DEV,
|
||||
DOMAIN,
|
||||
OPT_AUTO_UPDATE,
|
||||
)
|
||||
from .coordinator import ServerVersionCoordinator
|
||||
from .embedded_server import _installed_dist_version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# GitHub releases API for the release-notes surface (stable channel only - the
|
||||
# dev channel has no tagged releases, see release_url / supported_features).
|
||||
_RELEASES_URL = (
|
||||
"https://api.github.com/repos/homeassistant-ai/ha-mcp/releases?per_page=30"
|
||||
)
|
||||
_RELEASE_NOTES_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Add the single server-package update entity for this config entry."""
|
||||
coordinator: ServerVersionCoordinator = hass.data[DOMAIN][DATA_UPDATE_COORDINATOR]
|
||||
async_add_entities([ServerUpdateEntity(coordinator, entry)])
|
||||
|
||||
|
||||
class ServerUpdateEntity(CoordinatorEntity[ServerVersionCoordinator], UpdateEntity):
|
||||
"""Update entity for the ha-mcp server package the "server" entry runs."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "server_update"
|
||||
|
||||
def __init__(
|
||||
self, coordinator: ServerVersionCoordinator, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Bind to the coordinator and the owning config entry."""
|
||||
super().__init__(coordinator)
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_server_update"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Group under one device per config entry; sw_version = installed."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||
name="HA-MCP Server",
|
||||
manufacturer="homeassistant-ai",
|
||||
model="ha-mcp (in-process server)",
|
||||
sw_version=self.installed_version,
|
||||
configuration_url="https://github.com/homeassistant-ai/ha-mcp",
|
||||
)
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Return the installed server-package version, or None if unknown."""
|
||||
data = self.coordinator.data
|
||||
return data.installed if data is not None else None
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
"""Return the newest PyPI version, or None if unknown/unresolvable."""
|
||||
data = self.coordinator.data
|
||||
return data.latest if data is not None else None
|
||||
|
||||
@property
|
||||
def auto_update(self) -> bool:
|
||||
"""Reflect the entry's automatic-update option."""
|
||||
return bool(self._entry.options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE))
|
||||
|
||||
@property
|
||||
def release_url(self) -> str | None:
|
||||
"""Stable: the tagged GitHub release. Dev: the commit history (no tags)."""
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
if data.dist == DIST_NAME_DEV:
|
||||
return "https://github.com/homeassistant-ai/ha-mcp/commits/master"
|
||||
if data.latest is None:
|
||||
return None
|
||||
return f"https://github.com/homeassistant-ai/ha-mcp/releases/tag/v{data.latest}"
|
||||
|
||||
@property
|
||||
def supported_features(self) -> UpdateEntityFeature:
|
||||
"""RELEASE_NOTES only on the stable channel — dev builds have no tags."""
|
||||
features = UpdateEntityFeature.INSTALL
|
||||
data = self.coordinator.data
|
||||
if data is not None and data.dist != DIST_NAME_DEV:
|
||||
features |= UpdateEntityFeature.RELEASE_NOTES
|
||||
return features
|
||||
|
||||
async def async_release_notes(self) -> str | None:
|
||||
"""Concatenate GitHub release bodies between installed and latest.
|
||||
|
||||
Advisory-only (same reasoning as embedded_setup's
|
||||
_async_check_component_compat): a GitHub fetch failure, rate limit, or
|
||||
unexpected payload shape must degrade to None - the UI then falls back
|
||||
to :attr:`release_url` - rather than break the update dialog.
|
||||
"""
|
||||
data = self.coordinator.data
|
||||
if data is None or data.installed is None or data.latest is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
installed = AwesomeVersion(data.installed)
|
||||
latest = AwesomeVersion(data.latest)
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with asyncio.timeout(_RELEASE_NOTES_TIMEOUT_SECONDS):
|
||||
async with session.get(_RELEASES_URL) as resp:
|
||||
resp.raise_for_status()
|
||||
releases = await resp.json()
|
||||
|
||||
notes: list[tuple[AwesomeVersion, str]] = []
|
||||
for release in releases:
|
||||
tag = str(release.get("tag_name") or "").removeprefix("v")
|
||||
try:
|
||||
version = AwesomeVersion(tag)
|
||||
except AwesomeVersionException:
|
||||
continue
|
||||
if installed < version <= latest:
|
||||
notes.append((version, str(release.get("body") or "")))
|
||||
except (ClientError, TimeoutError) as err:
|
||||
# Expected transients (GitHub unreachable, rate-limited, slow) —
|
||||
# quiet; the dialog falls back to release_url.
|
||||
_LOGGER.debug("HA-MCP release-notes fetch failed: %s", err)
|
||||
return None
|
||||
except Exception:
|
||||
# An unexpected payload shape (TypeError/AttributeError in the
|
||||
# parse loop) is a bug or a GitHub API change — logged visibly per
|
||||
# the repo's convention (review finding), still degrading to the
|
||||
# release_url fallback rather than breaking the update dialog.
|
||||
_LOGGER.warning("HA-MCP release-notes fetch failed", exc_info=True)
|
||||
return None
|
||||
|
||||
if not notes:
|
||||
return None
|
||||
notes.sort(key=lambda item: item[0], reverse=True)
|
||||
return "\n\n---\n\n".join(body for _, body in notes)
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Reinstall pinned to ``version`` (or the latest known build).
|
||||
|
||||
With auto-update off, ``_resolve_pip_spec`` pins the install to the
|
||||
currently-installed version, so a bare reload would just reinstall the
|
||||
same build. The one-shot pending-install marker overrides that pin for
|
||||
this single reload; embedded_server clears it when it consumes it (one
|
||||
marker buys one attempt). The reload only completes entry SETUP — the
|
||||
pip install runs in the reloaded entry's background bring-up — so this
|
||||
waits for that bring-up and verifies the requested version actually
|
||||
landed; returning at reload time would report success for an install
|
||||
that can still fail (review finding).
|
||||
"""
|
||||
data = self.coordinator.data
|
||||
target = version or self.latest_version
|
||||
if target is None:
|
||||
raise HomeAssistantError("No target version available to install.")
|
||||
# Broad except is intentional here (unlike this repo's usual narrow
|
||||
# convention): async_install feeds Home Assistant's update UI, which
|
||||
# expects a HomeAssistantError for ANY failure rather than an opaque
|
||||
# traceback in the install dialog. Logged with traceback first so a
|
||||
# genuine bug still reaches the log (review finding).
|
||||
try:
|
||||
new_data = {**self._entry.data, DATA_PENDING_INSTALL_VERSION: target}
|
||||
self.hass.config_entries.async_update_entry(self._entry, data=new_data)
|
||||
await self.hass.config_entries.async_reload(self._entry.entry_id)
|
||||
# The reloaded entry's bring-up task does the actual install; it
|
||||
# contains its own failures (files repair issues instead of
|
||||
# raising), so awaiting it tells us the attempt is over, not that
|
||||
# it worked — the version read below is the success check.
|
||||
bringup = self.hass.data.get(DOMAIN, {}).get(DATA_BRINGUP_TASK)
|
||||
if bringup is not None:
|
||||
await bringup
|
||||
installed: str | None = None
|
||||
if data is not None:
|
||||
installed = await self.hass.async_add_executor_job(
|
||||
_installed_dist_version, data.dist
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.exception("HA-MCP server update install failed")
|
||||
raise HomeAssistantError(
|
||||
f"Could not install the HA-MCP server update: {err}"
|
||||
) from err
|
||||
# Outside the broad except: these raises must reach the UI as-is, not
|
||||
# get re-wrapped into the generic message.
|
||||
if data is not None and installed != target:
|
||||
raise HomeAssistantError(
|
||||
f"The HA-MCP server update to {target} did not complete "
|
||||
f"(installed: {installed or 'none'}). See Settings > Repairs "
|
||||
"for the failure details."
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
"""ruamel.yaml round-trip helpers preserving comments and HA custom tags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
|
||||
class _TaggedScalar:
|
||||
"""Wrapper that stores a YAML tag + scalar value for lossless round-trip."""
|
||||
|
||||
__slots__ = ("tag", "value")
|
||||
|
||||
def __init__(self, tag: str, value: str) -> None:
|
||||
self.tag = tag
|
||||
self.value = value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"_TaggedScalar({self.tag!r}, {self.value!r})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, _TaggedScalar):
|
||||
return NotImplemented
|
||||
return self.tag == other.tag and self.value == other.value
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.tag, self.value))
|
||||
|
||||
|
||||
_HA_TAGS = (
|
||||
"!include",
|
||||
"!include_dir_list",
|
||||
"!include_dir_named",
|
||||
"!include_dir_merge_list",
|
||||
"!include_dir_merge_named",
|
||||
"!secret",
|
||||
"!env_var",
|
||||
)
|
||||
|
||||
|
||||
def _make_tag_constructor(tag: str) -> Callable[[Any, Any], _TaggedScalar]:
|
||||
"""Return a ruamel.yaml constructor function for *tag*."""
|
||||
|
||||
def _construct(loader: Any, node: Any) -> _TaggedScalar:
|
||||
return _TaggedScalar(tag, loader.construct_scalar(node))
|
||||
|
||||
return _construct
|
||||
|
||||
|
||||
def _represent_tagged_scalar(dumper: Any, data: _TaggedScalar) -> Any:
|
||||
"""Representer that emits the original tag + scalar value."""
|
||||
return dumper.represent_scalar(data.tag, data.value)
|
||||
|
||||
|
||||
def _register_ha_tags() -> None:
|
||||
"""Register HA tag constructors/representers on the shared class registries.
|
||||
|
||||
``add_constructor`` / ``add_representer`` mutate class-level registries
|
||||
shared by all ``YAML(typ="rt")`` instances. We call this once at import
|
||||
time; ``make_yaml()`` then only creates a fresh (thread-safe) instance.
|
||||
"""
|
||||
# Use a temporary instance to access the Constructor/Representer classes
|
||||
_tmp = YAML(typ="rt")
|
||||
for tag in _HA_TAGS:
|
||||
_tmp.Constructor.add_constructor(tag, _make_tag_constructor(tag))
|
||||
_tmp.Representer.add_representer(_TaggedScalar, _represent_tagged_scalar)
|
||||
|
||||
|
||||
_register_ha_tags()
|
||||
|
||||
|
||||
# Effectively-infinite emitter line width. ruamel's default (~80 columns)
|
||||
# re-wraps long lines on dump; inside a ``>`` folded scalar a new wrap
|
||||
# adjacent to a more-indented line becomes a LITERAL newline on re-parse,
|
||||
# silently corrupting string literals in blocks an edit never touched
|
||||
# (#1720). Never introducing new wraps also keeps untouched long lines
|
||||
# byte-stable across edits.
|
||||
_NEVER_WRAP_WIDTH = 2**31
|
||||
|
||||
|
||||
# A top-level mapping key: starts at column 0, `key:` with nothing (or a
|
||||
# comment) after the colon. Quoted/exotic keys never match — detection
|
||||
# then just falls back to the default style, which is safe.
|
||||
_TOP_LEVEL_KEY_RE = re.compile(r"^[A-Za-z0-9_][^\s:]*:\s*(?:#.*)?$")
|
||||
_DASH_RE = re.compile(r"^( *)- ")
|
||||
|
||||
# ruamel's compact defaults for block sequences (dash at the parent
|
||||
# column). Used to RESET the shared per-thread instance between dumps.
|
||||
_DEFAULT_SEQ_STYLE = (2, 0)
|
||||
|
||||
|
||||
def detect_seq_indent(text: str) -> tuple[int, int] | None:
|
||||
"""Detect the file's top-level block-sequence style.
|
||||
|
||||
Returns ``(sequence, offset)`` for ``YAML.indent()`` — derived from
|
||||
the first list item that directly follows a top-level key — or
|
||||
``None`` when the file has no such sequence. Only top-level
|
||||
sequences discriminate: nested dashes are indented in BOTH styles.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if not _TOP_LEVEL_KEY_RE.match(line):
|
||||
continue
|
||||
for nxt in lines[i + 1 :]:
|
||||
if not nxt.strip() or nxt.lstrip().startswith("#"):
|
||||
continue
|
||||
m = _DASH_RE.match(nxt)
|
||||
if m:
|
||||
offset = len(m.group(1))
|
||||
return (offset + 2, offset)
|
||||
break # value is not a sequence — try the next top-level key
|
||||
return None
|
||||
|
||||
|
||||
def apply_seq_indent(ry: YAML, style: tuple[int, int] | None) -> None:
|
||||
"""Apply a detected sequence style (or the compact default) to *ry*.
|
||||
|
||||
``make_yaml()`` instances are cached per-thread, so the style MUST be
|
||||
(re)applied before every dump — passing ``None`` resets to the
|
||||
default instead of leaking the previous file's style.
|
||||
"""
|
||||
sequence, offset = style if style is not None else _DEFAULT_SEQ_STYLE
|
||||
ry.indent(mapping=2, sequence=sequence, offset=offset)
|
||||
|
||||
|
||||
def _build_yaml() -> YAML:
|
||||
"""Create a fresh round-trip YAML instance with HA tag support."""
|
||||
ry = YAML(typ="rt")
|
||||
ry.preserve_quotes = True
|
||||
ry.width = _NEVER_WRAP_WIDTH
|
||||
return ry
|
||||
|
||||
|
||||
class _YAMLStorage(threading.local):
|
||||
"""Thread-local storage for ruamel.yaml instances."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.yaml = _build_yaml()
|
||||
|
||||
|
||||
_STORAGE = _YAMLStorage()
|
||||
|
||||
|
||||
def make_yaml() -> YAML:
|
||||
"""Return a round-trip YAML instance with HA tag support.
|
||||
|
||||
The instance is cached per-thread to prevent ruamel.yaml from performing
|
||||
expensive plugin discovery (glob/scandir) on every call, which
|
||||
causes CPU spikes and event loop blocking during bulk edits.
|
||||
|
||||
Thread-local storage is used because ruamel.yaml instances are not
|
||||
thread-safe.
|
||||
"""
|
||||
try:
|
||||
return _STORAGE.yaml
|
||||
except AttributeError:
|
||||
_STORAGE.yaml = _build_yaml()
|
||||
return _STORAGE.yaml
|
||||
|
||||
|
||||
def yaml_dumps(ry: YAML, data: Any) -> str:
|
||||
"""Dump *data* to a string using the given YAML instance."""
|
||||
buf = StringIO()
|
||||
ry.dump(data, buf)
|
||||
return buf.getvalue()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user