mirror of
https://github.com/home-assistant/core.git
synced 2026-04-08 16:35:17 +00:00
Compare commits
179 Commits
epenet/pru
...
event_ring
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0bd5329fb | ||
|
|
86229e6da0 | ||
|
|
d070400936 | ||
|
|
e1fa894572 | ||
|
|
43c83cc850 | ||
|
|
7f27915825 | ||
|
|
54a63d2c3e | ||
|
|
9709fbd6c6 | ||
|
|
12abff5b9e | ||
|
|
9b2abb0acc | ||
|
|
e7bc593fa8 | ||
|
|
2a4b8c88a8 | ||
|
|
a6c66a86ee | ||
|
|
6dfcc1c4f6 | ||
|
|
dfb61ee881 | ||
|
|
4776b99f5f | ||
|
|
49f5557947 | ||
|
|
4ed9113e35 | ||
|
|
e2688f909b | ||
|
|
9e1c521fed | ||
|
|
73fbc87639 | ||
|
|
8986477c96 | ||
|
|
ca40d68417 | ||
|
|
d2539ccaf2 | ||
|
|
56e7b8ddbb | ||
|
|
ca5aa215d2 | ||
|
|
d3ca5132fc | ||
|
|
f1d309779e | ||
|
|
85fa2415c1 | ||
|
|
ddc00f6924 | ||
|
|
2216fcccc7 | ||
|
|
6212d548b8 | ||
|
|
bf12323782 | ||
|
|
8b9ba690f1 | ||
|
|
3c7c0091f2 | ||
|
|
7bea4a53e2 | ||
|
|
50d9109e5f | ||
|
|
6d80b3769a | ||
|
|
9ff97ecd7b | ||
|
|
34dc52c732 | ||
|
|
67243a5044 | ||
|
|
74770f0b33 | ||
|
|
b1d81536ce | ||
|
|
b8d8b1cfa8 | ||
|
|
dfaed39a01 | ||
|
|
4849dc0eb9 | ||
|
|
05aaf8745d | ||
|
|
7c1abd993d | ||
|
|
0674de2ce4 | ||
|
|
ba26d119f7 | ||
|
|
616a0f204c | ||
|
|
0eaa8d38db | ||
|
|
4ad2f752a3 | ||
|
|
310af5a31a | ||
|
|
11fac8ee48 | ||
|
|
e70514f540 | ||
|
|
1a7465dd72 | ||
|
|
15ddce74a7 | ||
|
|
ee4c941610 | ||
|
|
459fc43625 | ||
|
|
893d9306d4 | ||
|
|
c243680113 | ||
|
|
db6d95273c | ||
|
|
a084e85028 | ||
|
|
1d7eb5ed15 | ||
|
|
7b19b5a416 | ||
|
|
2b0f2bcd12 | ||
|
|
5d5d00f0b2 | ||
|
|
735adb61a3 | ||
|
|
42f602a2fd | ||
|
|
287b38bebd | ||
|
|
415f711039 | ||
|
|
28ddb3a590 | ||
|
|
0f1dbba65a | ||
|
|
206d1ab3a8 | ||
|
|
cb15014dc0 | ||
|
|
a30fc9e9d5 | ||
|
|
b5480da68e | ||
|
|
a5a8cca424 | ||
|
|
5c890a8a2b | ||
|
|
dc45f86beb | ||
|
|
5ef506623d | ||
|
|
5632d308dd | ||
|
|
43210b845b | ||
|
|
d798aad2d7 | ||
|
|
4232db480a | ||
|
|
8b7d1f60a0 | ||
|
|
cb69e638b4 | ||
|
|
f3a020780d | ||
|
|
d1224e3f92 | ||
|
|
ef8a0a404c | ||
|
|
c86eb38cdb | ||
|
|
2333e9e8b1 | ||
|
|
f8476e4e84 | ||
|
|
d904175a2f | ||
|
|
193720b4c6 | ||
|
|
409b3f17db | ||
|
|
c3c1c3eb46 | ||
|
|
6b2a4df6e0 | ||
|
|
2ac3979f83 | ||
|
|
2fb44bce5d | ||
|
|
d187e61274 | ||
|
|
7f79da2f75 | ||
|
|
4871344138 | ||
|
|
b0ba740024 | ||
|
|
b6f49d2063 | ||
|
|
c8e2a2b520 | ||
|
|
27365a4457 | ||
|
|
4efcb5a700 | ||
|
|
4ffc4b8f71 | ||
|
|
e8fa61ae63 | ||
|
|
c6c469cc7a | ||
|
|
d51afe20e0 | ||
|
|
5dac5ef099 | ||
|
|
a1b93b418b | ||
|
|
22a96583a9 | ||
|
|
01ffa6a676 | ||
|
|
abf37849fb | ||
|
|
72cd7ed178 | ||
|
|
6305ea8cf2 | ||
|
|
815c30b213 | ||
|
|
68cc6df3e0 | ||
|
|
7f700c891a | ||
|
|
e33727a75a | ||
|
|
374a050636 | ||
|
|
90045e5539 | ||
|
|
e30c379979 | ||
|
|
86d80c96d7 | ||
|
|
1bbecec991 | ||
|
|
3970a27369 | ||
|
|
745107c192 | ||
|
|
24530d13af | ||
|
|
7529b9252c | ||
|
|
92a1c4568d | ||
|
|
c6527c9f6d | ||
|
|
05b7fa9602 | ||
|
|
375bd55ae6 | ||
|
|
fbd0cb8666 | ||
|
|
7e061262ad | ||
|
|
0e9c17fb16 | ||
|
|
940de5ea84 | ||
|
|
bf4773d9bc | ||
|
|
0bedcc55ce | ||
|
|
313f97fc47 | ||
|
|
b7d32e0650 | ||
|
|
b9afb2a861 | ||
|
|
d3a01d4c80 | ||
|
|
05bd2d05f5 | ||
|
|
23469d8950 | ||
|
|
26f677dcd1 | ||
|
|
484d9b0cbe | ||
|
|
03ed46aa07 | ||
|
|
e5f4000ac2 | ||
|
|
406598dbfa | ||
|
|
7f23a35155 | ||
|
|
0e521eda2e | ||
|
|
5a72dc8eca | ||
|
|
b11292385f | ||
|
|
2179a5405a | ||
|
|
78f5989cd6 | ||
|
|
cca44c675c | ||
|
|
0ebe65c25b | ||
|
|
f7ee95c4b9 | ||
|
|
d78c05ab62 | ||
|
|
9f41e3341f | ||
|
|
8ab3d482b9 | ||
|
|
a485c3d410 | ||
|
|
38b27d624a | ||
|
|
f437d65d3c | ||
|
|
5ba0764a87 | ||
|
|
69fd6532cc | ||
|
|
ee8bd9f016 | ||
|
|
de5a2d47a5 | ||
|
|
54b2e0285c | ||
|
|
a0e118d411 | ||
|
|
07c33233ee | ||
|
|
962cac902b | ||
|
|
9ff5c9863f | ||
|
|
b60e396241 |
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -11,10 +11,9 @@
|
||||
|
||||
This repository contains the core of Home Assistant, a Python 3 based home automation application.
|
||||
|
||||
## Code Review Guidelines
|
||||
## Git Commit Guidelines
|
||||
|
||||
**Git commit practices during review:**
|
||||
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
|
||||
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Get information
|
||||
id: info
|
||||
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]
|
||||
uses: home-assistant/actions/helpers/info@5752577ea7cc5aefb064b0b21432f18fe4d6ba90 # zizmor: ignore[unpinned-uses]
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -1392,7 +1392,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1563,7 +1563,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1591,7 +1591,7 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -87,6 +87,13 @@ repos:
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(homeassistant/.+/manifest\.json|homeassistant/brands/.+\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$
|
||||
- id: gen_copilot_instructions
|
||||
name: gen_copilot_instructions
|
||||
entry: script/run-in-env.sh python3 -m script.gen_copilot_instructions
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(AGENTS\.md|\.claude/skills/(?!github-pr-reviewer/).+/SKILL\.md|\.github/copilot-instructions\.md|script/gen_copilot_instructions\.py)$
|
||||
- id: hassfest
|
||||
name: hassfest
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
This repository contains the core of Home Assistant, a Python 3 based home automation application.
|
||||
|
||||
## Code Review Guidelines
|
||||
## Git Commit Guidelines
|
||||
|
||||
**Git commit practices during review:**
|
||||
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
|
||||
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
11
CODEOWNERS
generated
11
CODEOWNERS
generated
@@ -37,6 +37,13 @@ build.json @home-assistant/supervisor
|
||||
# Other code
|
||||
/homeassistant/scripts/check_config.py @kellerza
|
||||
|
||||
# Agent Configurations
|
||||
AGENTS.md @home-assistant/core
|
||||
CLAUDE.md @home-assistant/core
|
||||
/.agent/ @home-assistant/core
|
||||
/.claude/ @home-assistant/core
|
||||
/.gemini/ @home-assistant/core
|
||||
|
||||
# Integrations
|
||||
/homeassistant/components/abode/ @shred86
|
||||
/tests/components/abode/ @shred86
|
||||
@@ -1301,6 +1308,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/pi_hole/ @shenxn
|
||||
/homeassistant/components/picnic/ @corneyl @codesalatdev
|
||||
/tests/components/picnic/ @corneyl @codesalatdev
|
||||
/homeassistant/components/picotts/ @rooggiieerr
|
||||
/tests/components/picotts/ @rooggiieerr
|
||||
/homeassistant/components/ping/ @jpbede
|
||||
/tests/components/ping/ @jpbede
|
||||
/homeassistant/components/plaato/ @JohNan
|
||||
@@ -1866,6 +1875,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/victron_ble/ @rajlaud
|
||||
/tests/components/victron_ble/ @rajlaud
|
||||
/homeassistant/components/victron_gx/ @tomer-w
|
||||
/tests/components/victron_gx/ @tomer-w
|
||||
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "victron",
|
||||
"name": "Victron",
|
||||
"integrations": ["victron_ble", "victron_remote_monitoring"]
|
||||
"integrations": ["victron_gx", "victron_ble", "victron_remote_monitoring"]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
|
||||
translation_key="auth_error",
|
||||
) from err
|
||||
except ActronAirAPIError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_connection_error",
|
||||
) from err
|
||||
|
||||
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
|
||||
for system in systems:
|
||||
|
||||
@@ -64,7 +64,7 @@ rules:
|
||||
status: exempt
|
||||
comment: Not required for this integration at this stage.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"auth_error": {
|
||||
"message": "Authentication failed, please reauthenticate"
|
||||
},
|
||||
"setup_connection_error": {
|
||||
"message": "Failed to connect to the Actron Air API"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ async def _resolve_attachments(
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=image_data.content_type,
|
||||
mime_type=attachment.get("media_content_type")
|
||||
or image_data.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
@@ -89,7 +90,7 @@ async def _resolve_attachments(
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=media.mime_type,
|
||||
mime_type=attachment.get("media_content_type") or media.mime_type,
|
||||
path=media.path,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -92,6 +92,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
_LOGGER.debug("Updating statistics for the first time")
|
||||
usage_sum = 0.0
|
||||
last_stats_time = None
|
||||
allow_update_last_stored_hour = False
|
||||
else:
|
||||
if not meter.readings or len(meter.readings) == 0:
|
||||
_LOGGER.debug("No recent usage statistics found, skipping update")
|
||||
@@ -107,6 +108,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
continue
|
||||
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
_LOGGER.debug("Getting statistics at %s", start)
|
||||
stats: dict[str, list[Any]] = {}
|
||||
for end in (start + timedelta(seconds=1), None):
|
||||
stats = await get_instance(self.hass).async_add_executor_job(
|
||||
statistics_during_period,
|
||||
@@ -127,15 +129,28 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"Not found, trying to find oldest statistic after %s",
|
||||
start,
|
||||
)
|
||||
assert stats
|
||||
|
||||
def _safe_get_sum(records: list[Any]) -> float:
|
||||
if records and "sum" in records[0]:
|
||||
return float(records[0]["sum"])
|
||||
return 0.0
|
||||
if not stats or not stats.get(usage_statistic_id):
|
||||
_LOGGER.debug(
|
||||
"Could not find existing statistics during period lookup for %s, "
|
||||
"falling back to last stored statistic",
|
||||
usage_statistic_id,
|
||||
)
|
||||
allow_update_last_stored_hour = True
|
||||
last_records = last_stat[usage_statistic_id]
|
||||
usage_sum = float(last_records[0].get("sum") or 0.0)
|
||||
last_stats_time = last_records[0]["start"]
|
||||
else:
|
||||
allow_update_last_stored_hour = False
|
||||
records = stats[usage_statistic_id]
|
||||
|
||||
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
|
||||
last_stats_time = stats[usage_statistic_id][0]["start"]
|
||||
def _safe_get_sum(records: list[Any]) -> float:
|
||||
if records and "sum" in records[0]:
|
||||
return float(records[0]["sum"])
|
||||
return 0.0
|
||||
|
||||
usage_sum = _safe_get_sum(records)
|
||||
last_stats_time = records[0]["start"]
|
||||
|
||||
usage_statistics = []
|
||||
|
||||
@@ -148,7 +163,13 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
continue
|
||||
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
|
||||
if last_stats_time is not None and start.timestamp() <= last_stats_time:
|
||||
if last_stats_time is not None and (
|
||||
start.timestamp() < last_stats_time
|
||||
or (
|
||||
start.timestamp() == last_stats_time
|
||||
and not allow_update_last_stored_hour
|
||||
)
|
||||
):
|
||||
continue
|
||||
usage_state = max(0, read["consumption"] / 1000)
|
||||
usage_sum = max(0, read["read"])
|
||||
|
||||
@@ -48,10 +48,12 @@ from .const import (
|
||||
CONF_CODE_EXECUTION,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_PROMPT_CACHING,
|
||||
CONF_RECOMMENDED,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_TOOL_SEARCH,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
@@ -65,7 +67,9 @@ from .const import (
|
||||
DOMAIN,
|
||||
NON_ADAPTIVE_THINKING_MODELS,
|
||||
NON_THINKING_MODELS,
|
||||
TOOL_SEARCH_UNSUPPORTED_MODELS,
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS,
|
||||
PromptCaching,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -356,6 +360,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
CONF_TEMPERATURE,
|
||||
default=DEFAULT[CONF_TEMPERATURE],
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_PROMPT_CACHING,
|
||||
default=DEFAULT[CONF_PROMPT_CACHING],
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[x.value for x in PromptCaching],
|
||||
translation_key=CONF_PROMPT_CACHING,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
if user_input is not None:
|
||||
@@ -454,6 +468,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
self.options.pop(CONF_WEB_SEARCH_COUNTRY, None)
|
||||
self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
|
||||
|
||||
if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)):
|
||||
step_schema[
|
||||
vol.Optional(
|
||||
CONF_TOOL_SEARCH,
|
||||
default=DEFAULT[CONF_TOOL_SEARCH],
|
||||
)
|
||||
] = bool
|
||||
else:
|
||||
self.options.pop(CONF_TOOL_SEARCH, None)
|
||||
|
||||
if not step_schema:
|
||||
user_input = {}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Constants for the Anthropic integration."""
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
|
||||
DOMAIN = "anthropic"
|
||||
@@ -13,9 +14,11 @@ CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
CONF_CODE_EXECUTION = "code_execution"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
CONF_PROMPT_CACHING = "prompt_caching"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
CONF_THINKING_BUDGET = "thinking_budget"
|
||||
CONF_THINKING_EFFORT = "thinking_effort"
|
||||
CONF_TOOL_SEARCH = "tool_search"
|
||||
CONF_WEB_SEARCH = "web_search"
|
||||
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
|
||||
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
|
||||
@@ -24,13 +27,24 @@ CONF_WEB_SEARCH_REGION = "region"
|
||||
CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
|
||||
class PromptCaching(StrEnum):
|
||||
"""Prompt caching options."""
|
||||
|
||||
OFF = "off"
|
||||
PROMPT = "prompt"
|
||||
AUTOMATIC = "automatic"
|
||||
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-haiku-4-5",
|
||||
CONF_CODE_EXECUTION: False,
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
CONF_PROMPT_CACHING: PromptCaching.PROMPT.value,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
CONF_THINKING_EFFORT: "low",
|
||||
CONF_TOOL_SEARCH: False,
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
@@ -81,6 +95,11 @@ PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
TOOL_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3",
|
||||
"claude-haiku",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
@@ -58,6 +58,8 @@ from anthropic.types import (
|
||||
ToolChoiceAutoParam,
|
||||
ToolChoiceToolParam,
|
||||
ToolParam,
|
||||
ToolSearchToolBm25_20251119Param,
|
||||
ToolSearchToolResultBlock,
|
||||
ToolUnionParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
@@ -74,6 +76,9 @@ from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
|
||||
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.tool_search_tool_result_block_param import (
|
||||
Content as ToolSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
@@ -91,9 +96,11 @@ from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_CODE_EXECUTION,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT_CACHING,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_TOOL_SEARCH,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
@@ -109,6 +116,7 @@ from .const import (
|
||||
NON_THINKING_MODELS,
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
|
||||
PromptCaching,
|
||||
)
|
||||
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
|
||||
|
||||
@@ -202,7 +210,7 @@ class ContentDetails:
|
||||
]
|
||||
|
||||
|
||||
def _convert_content(
|
||||
def _convert_content( # noqa: C901
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> tuple[list[MessageParam], str | None]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
@@ -255,6 +263,15 @@ def _convert_content(
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "tool_search":
|
||||
tool_result_block = {
|
||||
"type": "tool_search_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
ToolSearchToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
else:
|
||||
tool_result_block = {
|
||||
"type": "tool_result",
|
||||
@@ -385,6 +402,7 @@ def _convert_content(
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
"tool_search_tool_bm25",
|
||||
],
|
||||
tool_call.tool_name,
|
||||
),
|
||||
@@ -397,6 +415,7 @@ def _convert_content(
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
"tool_search_tool_bm25",
|
||||
]
|
||||
else ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
@@ -558,6 +577,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
ToolSearchToolResultBlock,
|
||||
),
|
||||
):
|
||||
if content_details:
|
||||
@@ -678,7 +698,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
async def _async_handle_chat_log( # noqa: C901
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
structure_name: str | None = None,
|
||||
@@ -688,21 +708,20 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
preloaded_tools = [
|
||||
"HassTurnOn",
|
||||
"HassTurnOff",
|
||||
"GetLiveContext",
|
||||
"code_execution",
|
||||
"web_search",
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="system_message_not_found"
|
||||
)
|
||||
|
||||
# System prompt with caching enabled
|
||||
system_prompt: list[TextBlockParam] = [
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=system.content,
|
||||
cache_control={"type": "ephemeral"},
|
||||
)
|
||||
]
|
||||
|
||||
messages, container_id = _convert_content(chat_log.content[1:])
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
|
||||
@@ -711,11 +730,28 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
|
||||
system=system_prompt,
|
||||
system=system.content,
|
||||
stream=True,
|
||||
container=container_id,
|
||||
)
|
||||
|
||||
if (
|
||||
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
|
||||
== PromptCaching.PROMPT
|
||||
):
|
||||
model_args["system"] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": system.content,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
]
|
||||
elif (
|
||||
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
|
||||
== PromptCaching.AUTOMATIC
|
||||
):
|
||||
model_args["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
|
||||
thinking_effort = options.get(
|
||||
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
|
||||
@@ -874,8 +910,23 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
),
|
||||
)
|
||||
)
|
||||
preloaded_tools.append(structure_name)
|
||||
|
||||
if tools:
|
||||
if (
|
||||
options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH])
|
||||
and len(tools) > len(preloaded_tools) + 1
|
||||
):
|
||||
for tool in tools:
|
||||
if not tool["name"].endswith(tuple(preloaded_tools)):
|
||||
tool["defer_loading"] = True
|
||||
tools.append(
|
||||
ToolSearchToolBm25_20251119Param(
|
||||
type="tool_search_tool_bm25_20251119",
|
||||
name="tool_search_tool_bm25",
|
||||
)
|
||||
)
|
||||
|
||||
model_args["tools"] = tools
|
||||
|
||||
coordinator = self.entry.runtime_data
|
||||
@@ -919,6 +970,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
except anthropic.AnthropicError as err:
|
||||
# Non-connection error, mark connection as healthy
|
||||
coordinator.async_set_updated_data(None)
|
||||
LOGGER.error("Error while talking to Anthropic: %s", err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["anthropic==0.83.0"]
|
||||
}
|
||||
|
||||
@@ -47,11 +47,13 @@
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
|
||||
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::max_tokens%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
|
||||
@@ -72,6 +74,7 @@
|
||||
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]",
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
|
||||
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
|
||||
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
|
||||
@@ -80,6 +83,7 @@
|
||||
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::code_execution%]",
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
|
||||
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
|
||||
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
|
||||
@@ -103,11 +107,13 @@
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"prompt_caching": "Caching strategy",
|
||||
"temperature": "Temperature"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "The model to serve the responses.",
|
||||
"max_tokens": "Limit the number of response tokens.",
|
||||
"prompt_caching": "Optimize your API cost and response times based on your usage.",
|
||||
"temperature": "Control the randomness of the response, trading off between creativity and coherence."
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
@@ -132,6 +138,7 @@
|
||||
"code_execution": "Code execution",
|
||||
"thinking_budget": "Thinking budget",
|
||||
"thinking_effort": "Thinking effort",
|
||||
"tool_search": "Enable tool search tool",
|
||||
"user_location": "Include home location",
|
||||
"web_search": "Enable web search",
|
||||
"web_search_max_uses": "Maximum web searches"
|
||||
@@ -140,6 +147,7 @@
|
||||
"code_execution": "Allow the model to execute code in a secure sandbox environment, enabling it to analyze data and perform complex calculations.",
|
||||
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.",
|
||||
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
|
||||
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
|
||||
"user_location": "Localize search results based on home location",
|
||||
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
|
||||
"web_search_max_uses": "Limit the number of searches performed per response"
|
||||
@@ -210,6 +218,13 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"prompt_caching": {
|
||||
"options": {
|
||||
"automatic": "Full",
|
||||
"off": "Disabled",
|
||||
"prompt": "System prompt"
|
||||
}
|
||||
},
|
||||
"thinking_effort": {
|
||||
"options": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["arcam"],
|
||||
"requirements": ["arcam-fmj==1.8.2"],
|
||||
"requirements": ["arcam-fmj==1.8.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -91,6 +91,7 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
|
||||
value_fn=lambda state: (
|
||||
vp.colorspace.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
and vp.colorspace is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"services": {
|
||||
"announce": {
|
||||
"description": "Lets a satellite announce a message.",
|
||||
"description": "Lets an Assist satellite announce a message.",
|
||||
"fields": {
|
||||
"media_id": {
|
||||
"description": "The media ID to announce instead of using text-to-speech.",
|
||||
@@ -94,10 +94,10 @@
|
||||
"name": "Preannounce media ID"
|
||||
}
|
||||
},
|
||||
"name": "Announce"
|
||||
"name": "Announce on satellite"
|
||||
},
|
||||
"ask_question": {
|
||||
"description": "Asks a question and gets the user's response.",
|
||||
"description": "Lets an Assist satellite ask a question and get the user's response.",
|
||||
"fields": {
|
||||
"answers": {
|
||||
"description": "Possible answers to the question.",
|
||||
@@ -124,10 +124,10 @@
|
||||
"name": "Question media ID"
|
||||
}
|
||||
},
|
||||
"name": "Ask question"
|
||||
"name": "Ask question on satellite"
|
||||
},
|
||||
"start_conversation": {
|
||||
"description": "Starts a conversation from a satellite.",
|
||||
"description": "Starts a conversation from an Assist satellite.",
|
||||
"fields": {
|
||||
"extra_system_prompt": {
|
||||
"description": "Provide background information to the AI about the request.",
|
||||
@@ -150,13 +150,13 @@
|
||||
"name": "Message"
|
||||
}
|
||||
},
|
||||
"name": "Start conversation"
|
||||
"name": "Start conversation on satellite"
|
||||
}
|
||||
},
|
||||
"title": "Assist satellite",
|
||||
"triggers": {
|
||||
"idle": {
|
||||
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
|
||||
"description": "Triggers after one or more Assist satellites become idle after having processed a command.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -165,7 +165,7 @@
|
||||
"name": "Satellite became idle"
|
||||
},
|
||||
"listening": {
|
||||
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
|
||||
"description": "Triggers after one or more Assist satellites start listening for a command from someone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -174,7 +174,7 @@
|
||||
"name": "Satellite started listening"
|
||||
},
|
||||
"processing": {
|
||||
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
|
||||
"description": "Triggers after one or more Assist satellites start processing a command after having heard it.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -183,7 +183,7 @@
|
||||
"name": "Satellite started processing"
|
||||
},
|
||||
"responding": {
|
||||
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"description": "Triggers after one or more Assist satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==67"],
|
||||
"requirements": ["axis==68"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -74,6 +74,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except exception.BadRequest as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="bad_request",
|
||||
translation_placeholders={"error_message": str(err)},
|
||||
) from err
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
|
||||
@@ -174,6 +174,14 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
|
||||
)
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except exception.BadRequest as err:
|
||||
_LOGGER.error(
|
||||
"Backblaze B2 API rejected the request for Key ID '%s': %s",
|
||||
user_input[CONF_KEY_ID],
|
||||
err,
|
||||
)
|
||||
errors["base"] = "bad_request"
|
||||
placeholders["error_message"] = str(err)
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["b2sdk"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["b2sdk==2.10.1"]
|
||||
"requirements": ["b2sdk==2.10.4"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"bad_request": "The Backblaze B2 API rejected the request: {error_message}",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]",
|
||||
@@ -60,6 +61,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"bad_request": {
|
||||
"message": "The Backblaze B2 API rejected the request: {error_message}"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to endpoint"
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.11.1"
|
||||
"dbus-fast==4.0.4",
|
||||
"habluetooth==6.0.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
|
||||
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a [community integration]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
|
||||
"title": "The BMW Connected Drive integration has been removed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from bsblan import (
|
||||
BSBLAN,
|
||||
BSBLANAuthError,
|
||||
BSBLANConnectionError,
|
||||
BSBLANError,
|
||||
HotWaterConfig,
|
||||
HotWaterSchedule,
|
||||
HotWaterState,
|
||||
@@ -50,7 +51,7 @@ class BSBLanFastData:
|
||||
|
||||
state: State
|
||||
sensor: Sensor
|
||||
dhw: HotWaterState
|
||||
dhw: HotWaterState | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -111,7 +112,6 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
# This reduces response time significantly (~0.2s per parameter)
|
||||
state = await self.client.state(include=STATE_INCLUDE)
|
||||
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
|
||||
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
|
||||
|
||||
except BSBLANAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
@@ -126,6 +126,19 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
translation_placeholders={"host": host},
|
||||
) from err
|
||||
|
||||
# Fetch DHW state separately - device may not support hot water
|
||||
dhw: HotWaterState | None = None
|
||||
try:
|
||||
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
|
||||
except BSBLANError:
|
||||
# Preserve last known DHW state if available (entity may depend on it)
|
||||
if self.data:
|
||||
dhw = self.data.dhw
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) state not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
|
||||
return BSBLanFastData(
|
||||
state=state,
|
||||
sensor=sensor,
|
||||
@@ -159,13 +172,6 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
|
||||
dhw_schedule = await self.client.hot_water_schedule()
|
||||
|
||||
except AttributeError:
|
||||
# Device does not support DHW functionality
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
return BSBLanSlowData()
|
||||
except (BSBLANConnectionError, BSBLANAuthError) as err:
|
||||
# If config update fails, keep existing data
|
||||
LOGGER.debug(
|
||||
@@ -177,6 +183,13 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
return self.data
|
||||
# First fetch failed, return empty data
|
||||
return BSBLanSlowData()
|
||||
except BSBLANError, AttributeError:
|
||||
# Device does not support DHW functionality
|
||||
LOGGER.debug(
|
||||
"DHW (Domestic Hot Water) not available on device at %s",
|
||||
self.config_entry.data[CONF_HOST],
|
||||
)
|
||||
return BSBLanSlowData()
|
||||
|
||||
return BSBLanSlowData(
|
||||
dhw_config=dhw_config,
|
||||
|
||||
@@ -22,7 +22,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"fast_coordinator_data": {
|
||||
"state": data.fast_coordinator.data.state.model_dump(),
|
||||
"sensor": data.fast_coordinator.data.sensor.model_dump(),
|
||||
"dhw": data.fast_coordinator.data.dhw.model_dump(),
|
||||
"dhw": data.fast_coordinator.data.dhw.model_dump()
|
||||
if data.fast_coordinator.data.dhw
|
||||
else None,
|
||||
},
|
||||
"static": data.static.model_dump() if data.static is not None else None,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
@@ -10,7 +13,7 @@ from homeassistant.helpers.device_registry import (
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import BSBLanData
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
|
||||
|
||||
@@ -22,7 +25,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
|
||||
def __init__(self, coordinator: _T, data: BSBLanData) -> None:
|
||||
"""Initialize BSBLan entity with device info."""
|
||||
super().__init__(coordinator)
|
||||
host = coordinator.config_entry.data["host"]
|
||||
host = coordinator.config_entry.data[CONF_HOST]
|
||||
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
|
||||
mac = data.device.MAC
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac)},
|
||||
@@ -44,7 +48,7 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
|
||||
else None
|
||||
),
|
||||
sw_version=data.device.version,
|
||||
configuration_url=f"http://{host}",
|
||||
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from bsblan import BSBLANError, SetHotWaterParam
|
||||
from bsblan import BSBLANError, HotWaterState, SetHotWaterParam
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_ECO,
|
||||
@@ -46,8 +46,10 @@ async def async_setup_entry(
|
||||
data = entry.runtime_data
|
||||
|
||||
# Only create water heater entity if DHW (Domestic Hot Water) is available
|
||||
# Check if we have any DHW-related data indicating water heater support
|
||||
dhw_data = data.fast_coordinator.data.dhw
|
||||
if dhw_data is None:
|
||||
# Device does not support DHW, skip water heater setup
|
||||
return
|
||||
if (
|
||||
dhw_data.operating_mode is None
|
||||
and dhw_data.nominal_setpoint is None
|
||||
@@ -107,11 +109,21 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
else:
|
||||
self._attr_max_temp = 65.0 # Default maximum
|
||||
|
||||
@property
|
||||
def _dhw(self) -> HotWaterState:
|
||||
"""Return DHW state data.
|
||||
|
||||
This entity is only created when DHW data is available.
|
||||
"""
|
||||
dhw = self.coordinator.data.dhw
|
||||
assert dhw is not None
|
||||
return dhw
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return current operation."""
|
||||
if (
|
||||
operating_mode := self.coordinator.data.dhw.operating_mode
|
||||
operating_mode := self._dhw.operating_mode
|
||||
) is None or operating_mode.value is None:
|
||||
return None
|
||||
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
|
||||
@@ -119,16 +131,14 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if (
|
||||
current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature
|
||||
) is None:
|
||||
if (current_temp := self._dhw.dhw_actual_value_top_temperature) is None:
|
||||
return None
|
||||
return current_temp.value
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None:
|
||||
if (target_temp := self._dhw.nominal_setpoint) is None:
|
||||
return None
|
||||
return target_temp.value
|
||||
|
||||
|
||||
@@ -51,18 +51,24 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Each config entry represents a single device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: No user-configurable settings in the configuration flow.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not register repair issues.
|
||||
stale-devices: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Each config entry represents a single device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -13,6 +15,8 @@ from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
@@ -26,7 +30,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor platform for Casper Glow."""
|
||||
async_add_entities([CasperGlowBatterySensor(entry.runtime_data)])
|
||||
async_add_entities(
|
||||
[
|
||||
CasperGlowBatterySensor(entry.runtime_data),
|
||||
CasperGlowDimmingEndTimeSensor(entry.runtime_data),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity):
|
||||
@@ -59,3 +68,67 @@ class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity):
|
||||
if new_value != self._attr_native_value:
|
||||
self._attr_native_value = new_value
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class CasperGlowDimmingEndTimeSensor(CasperGlowEntity, SensorEntity):
|
||||
"""Sensor entity for Casper Glow dimming end time."""
|
||||
|
||||
_attr_translation_key = "dimming_end_time"
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize the dimming end time sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = (
|
||||
f"{format_mac(coordinator.device.address)}_dimming_end_time"
|
||||
)
|
||||
self._is_paused = False
|
||||
self._projected_end_time = ignore_variance(
|
||||
self._calculate_end_time,
|
||||
timedelta(minutes=1, seconds=30),
|
||||
)
|
||||
self._update_from_state(coordinator.device.state)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_end_time(remaining_ms: int) -> datetime:
|
||||
"""Calculate projected dimming end time from remaining milliseconds."""
|
||||
return utcnow() + timedelta(milliseconds=remaining_ms)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state update callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
def _reset_projected_end_time(self) -> None:
|
||||
"""Clear the projected end time and reset the variance filter."""
|
||||
self._attr_native_value = None
|
||||
self._projected_end_time = ignore_variance(
|
||||
self._calculate_end_time,
|
||||
timedelta(minutes=1, seconds=30),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_state(self, state: GlowState) -> None:
|
||||
"""Update entity attributes from device state."""
|
||||
if state.is_paused is not None:
|
||||
self._is_paused = state.is_paused
|
||||
|
||||
if self._is_paused:
|
||||
self._reset_projected_end_time()
|
||||
return
|
||||
|
||||
remaining_ms = state.dimming_time_remaining_ms
|
||||
if not remaining_ms:
|
||||
if remaining_ms == 0 or state.is_on is False:
|
||||
self._reset_projected_end_time()
|
||||
return
|
||||
self._attr_native_value = self._projected_end_time(remaining_ms)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
self._update_from_state(state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
"dimming_time": {
|
||||
"name": "Dimming time"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"dimming_end_time": {
|
||||
"name": "Dimming end time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -44,10 +44,10 @@
|
||||
},
|
||||
"services": {
|
||||
"show_lovelace_view": {
|
||||
"description": "Shows a dashboard view on a Chromecast device.",
|
||||
"description": "Shows a dashboard view on a Google Cast device.",
|
||||
"fields": {
|
||||
"dashboard_path": {
|
||||
"description": "The URL path of the dashboard to show, defaults to lovelace if not specified.",
|
||||
"description": "The URL path of the dashboard to show, defaults to `lovelace` if not specified.",
|
||||
"name": "Dashboard path"
|
||||
},
|
||||
"entity_id": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"name": "View path"
|
||||
}
|
||||
},
|
||||
"name": "Show dashboard view"
|
||||
"name": "Show dashboard view via Google Cast"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
"message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}."
|
||||
},
|
||||
"low_temp_higher_than_high_temp": {
|
||||
"message": "'Lower target temperature' can not be higher than 'Upper target temperature'."
|
||||
"message": "'Lower target temperature' cannot be higher than 'Upper target temperature'."
|
||||
},
|
||||
"missing_target_temperature_entity_feature": {
|
||||
"message": "Set temperature action was used with the 'Target temperature' parameter but the entity does not support it."
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
"requirements": ["aiocomelit==2.0.2"]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ from .const import ( # noqa: F401
|
||||
ATTR_DEV_ID,
|
||||
ATTR_GPS,
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_IP,
|
||||
ATTR_LOCATION_NAME,
|
||||
ATTR_MAC,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import final
|
||||
from typing import Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
STATE_NOT_HOME,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import (
|
||||
DeviceInfo,
|
||||
@@ -33,6 +33,7 @@ from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
ATTR_HOST_NAME,
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
@@ -223,6 +224,9 @@ class TrackerEntity(
|
||||
_attr_longitude: float | None = None
|
||||
_attr_source_type: SourceType = SourceType.GPS
|
||||
|
||||
__active_zone: State | None = None
|
||||
__in_zones: list[str] | None = None
|
||||
|
||||
@cached_property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling for entities that have location pushed."""
|
||||
@@ -256,6 +260,18 @@ class TrackerEntity(
|
||||
"""Return longitude value of the device."""
|
||||
return self._attr_longitude
|
||||
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Calculate active zones."""
|
||||
if self.available and self.latitude is not None and self.longitude is not None:
|
||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
else:
|
||||
self.__active_zone = None
|
||||
self.__in_zones = None
|
||||
super()._async_write_ha_state()
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
@@ -263,9 +279,7 @@ class TrackerEntity(
|
||||
return self.location_name
|
||||
|
||||
if self.latitude is not None and self.longitude is not None:
|
||||
zone_state = zone.async_active_zone(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
zone_state = self.__active_zone
|
||||
if zone_state is None:
|
||||
state = STATE_NOT_HOME
|
||||
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
|
||||
@@ -278,12 +292,13 @@ class TrackerEntity(
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, StateType] = {}
|
||||
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
|
||||
attr.update(super().state_attributes)
|
||||
|
||||
if self.latitude is not None and self.longitude is not None:
|
||||
attr[ATTR_IN_ZONES] = self.__in_zones or []
|
||||
attr[ATTR_LATITUDE] = self.latitude
|
||||
attr[ATTR_LONGITUDE] = self.longitude
|
||||
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
|
||||
|
||||
@@ -43,6 +43,7 @@ ATTR_BATTERY: Final = "battery"
|
||||
ATTR_DEV_ID: Final = "dev_id"
|
||||
ATTR_GPS: Final = "gps"
|
||||
ATTR_HOST_NAME: Final = "host_name"
|
||||
ATTR_IN_ZONES: Final = "in_zones"
|
||||
ATTR_LOCATION_NAME: Final = "location_name"
|
||||
ATTR_MAC: Final = "mac"
|
||||
ATTR_SOURCE_TYPE: Final = "source_type"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]"
|
||||
},
|
||||
"error": {
|
||||
"ambiguous_identifier": "The region identifier and device tracker can not be specified together.",
|
||||
"ambiguous_identifier": "The region identifier and device tracker cannot be specified together.",
|
||||
"attribute_not_found": "The required attributes 'Latitude' and 'Longitude' were not found in the specified device tracker.",
|
||||
"entity_not_found": "The specified device tracker entity was not found.",
|
||||
"invalid_identifier": "The specified region identifier / device tracker is invalid.",
|
||||
|
||||
@@ -24,11 +24,10 @@ class EcowittEntity(Entity):
|
||||
|
||||
self._attr_unique_id = f"{sensor.station.key}-{sensor.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, sensor.station.key),
|
||||
},
|
||||
identifiers={(DOMAIN, sensor.station.key)},
|
||||
name=sensor.station.model,
|
||||
model=sensor.station.model,
|
||||
manufacturer="Ecowitt",
|
||||
sw_version=sensor.station.version,
|
||||
)
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
|
||||
|
||||
|
||||
class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
|
||||
"""ekey bionyx authentication before a ConfigEntry exists.
|
||||
"""Authentication implementation used during config flow, without refresh.
|
||||
|
||||
This implementation directly provides the token without supporting refresh.
|
||||
This exists to allow the config flow to use the API before it has fully
|
||||
created a config entry required by OAuth2Session. This does not support
|
||||
refreshing tokens, which is fine since it should have been just created.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -6,25 +6,17 @@ rules:
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: The integration uses a push-based mechanism with a background sync task, not polling.
|
||||
brands:
|
||||
status: done
|
||||
common-modules:
|
||||
status: done
|
||||
config-flow-test-coverage:
|
||||
status: done
|
||||
config-flow:
|
||||
status: done
|
||||
dependency-transparency:
|
||||
status: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: The integration does not expose any custom service actions.
|
||||
docs-high-level-description:
|
||||
status: done
|
||||
docs-installation-instructions:
|
||||
status: done
|
||||
docs-removal-instructions:
|
||||
status: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
@@ -34,40 +26,30 @@ rules:
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
runtime-data:
|
||||
status: done
|
||||
test-before-configure:
|
||||
status: done
|
||||
test-before-setup:
|
||||
status: done
|
||||
unique-config-entry:
|
||||
status: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: The integration does not expose any custom service actions.
|
||||
config-entry-unloading:
|
||||
status: done
|
||||
docs-configuration-parameters:
|
||||
status: done
|
||||
docs-installation-parameters:
|
||||
status: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
integration-owner:
|
||||
status: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: The integration logs a single message when the EnergyID service is unavailable.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
reauthentication-flow:
|
||||
status: done
|
||||
test-coverage:
|
||||
status: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
@@ -82,21 +64,15 @@ rules:
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No discovery mechanism is used.
|
||||
docs-data-update:
|
||||
status: done
|
||||
docs-examples:
|
||||
status: done
|
||||
docs-known-limitations:
|
||||
status: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: This is a service integration not tied to specific device models.
|
||||
docs-supported-functions:
|
||||
status: done
|
||||
docs-troubleshooting:
|
||||
status: done
|
||||
docs-use-cases:
|
||||
status: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: The integration creates a single device entry for the service connection.
|
||||
@@ -112,8 +88,7 @@ rules:
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
exception-translations:
|
||||
status: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: This integration does not create its own entities.
|
||||
@@ -128,10 +103,8 @@ rules:
|
||||
comment: Creates a single service device entry tied to the config entry.
|
||||
|
||||
# Platinum
|
||||
async-dependency:
|
||||
status: done
|
||||
inject-websession:
|
||||
status: done
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: Full strict typing compliance will be addressed in a future update.
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN
|
||||
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN, DoorbellEventType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
DATA_COMPONENT: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN)
|
||||
@@ -44,6 +44,7 @@ __all__ = [
|
||||
"DOMAIN",
|
||||
"PLATFORM_SCHEMA",
|
||||
"PLATFORM_SCHEMA_BASE",
|
||||
"DoorbellEventType",
|
||||
"EventDeviceClass",
|
||||
"EventEntity",
|
||||
"EventEntityDescription",
|
||||
@@ -189,6 +190,21 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the event entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
|
||||
if (
|
||||
self.device_class == EventDeviceClass.DOORBELL
|
||||
and DoorbellEventType.RING not in self.event_types
|
||||
):
|
||||
report_issue = self._suggest_report_issue()
|
||||
_LOGGER.warning(
|
||||
"Entity %s is a doorbell event entity but does not support "
|
||||
"the '%s' event type. This will stop working in "
|
||||
"Home Assistant 2027.4, please %s",
|
||||
self.entity_id,
|
||||
DoorbellEventType.RING,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
if (
|
||||
(state := await self.async_get_last_state())
|
||||
and state.state is not None
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
"""Provides the constants needed for the component."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
DOMAIN = "event"
|
||||
ATTR_EVENT_TYPE = "event_type"
|
||||
ATTR_EVENT_TYPES = "event_types"
|
||||
|
||||
|
||||
class DoorbellEventType(StrEnum):
|
||||
"""Standard event types for doorbell device class."""
|
||||
|
||||
RING = "ring"
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
"name": "Button"
|
||||
},
|
||||
"doorbell": {
|
||||
"name": "Doorbell"
|
||||
"name": "Doorbell",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"ring": "Ring"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"name": "Motion"
|
||||
|
||||
@@ -19,7 +19,7 @@ from pyfirefly.models import Account, Bill, Budget, Category, Currency
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -79,13 +79,13 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except FireflyConnectionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except FireflyTimeoutError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
|
||||
@@ -7,13 +7,14 @@ from typing import Any, cast
|
||||
|
||||
from fitbit import Fitbit
|
||||
from fitbit.exceptions import HTTPException, HTTPUnauthorized
|
||||
from fitbit_web_api import ApiClient, Configuration, DevicesApi
|
||||
from fitbit_web_api import ApiClient, Configuration, DevicesApi, UserApi
|
||||
from fitbit_web_api.exceptions import (
|
||||
ApiException,
|
||||
OpenApiException,
|
||||
UnauthorizedException,
|
||||
)
|
||||
from fitbit_web_api.models.device import Device
|
||||
from fitbit_web_api.models.user import User
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
@@ -24,7 +25,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import FitbitUnitSystem
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import FitbitProfile
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,7 +46,7 @@ class FitbitApi(ABC):
|
||||
) -> None:
|
||||
"""Initialize Fitbit auth."""
|
||||
self._hass = hass
|
||||
self._profile: FitbitProfile | None = None
|
||||
self._profile: User | None = None
|
||||
self._unit_system = unit_system
|
||||
|
||||
@abstractmethod
|
||||
@@ -74,18 +74,16 @@ class FitbitApi(ABC):
|
||||
configuration.access_token = token[CONF_ACCESS_TOKEN]
|
||||
return await self._hass.async_add_executor_job(ApiClient, configuration)
|
||||
|
||||
async def async_get_user_profile(self) -> FitbitProfile:
|
||||
async def async_get_user_profile(self) -> User:
|
||||
"""Return the user profile from the API."""
|
||||
if self._profile is None:
|
||||
client = await self._async_get_client()
|
||||
response: dict[str, Any] = await self._run(client.user_profile_get)
|
||||
_LOGGER.debug("user_profile_get=%s", response)
|
||||
profile = response["user"]
|
||||
self._profile = FitbitProfile(
|
||||
encoded_id=profile["encodedId"],
|
||||
display_name=profile["displayName"],
|
||||
locale=profile.get("locale"),
|
||||
)
|
||||
client = await self._async_get_fitbit_web_api()
|
||||
api = UserApi(client)
|
||||
api_response = await self._run_async(api.get_profile)
|
||||
if not api_response.user:
|
||||
raise FitbitApiException("No user profile returned from fitbit API")
|
||||
_LOGGER.debug("user_profile_get=%s", api_response.to_dict())
|
||||
self._profile = api_response.user
|
||||
return self._profile
|
||||
|
||||
async def async_get_unit_system(self) -> FitbitUnitSystem:
|
||||
|
||||
@@ -85,4 +85,6 @@ class OAuth2FlowHandler(
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=profile.display_name, data=data)
|
||||
return self.async_create_entry(
|
||||
title=profile.display_name or "Fitbit", data=data
|
||||
)
|
||||
|
||||
@@ -7,20 +7,6 @@ from typing import Any
|
||||
from .const import CONF_CLOCK_FORMAT, CONF_MONITORED_RESOURCES, FitbitScope
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitProfile:
|
||||
"""User profile from the Fitbit API response."""
|
||||
|
||||
encoded_id: str
|
||||
"""The ID representing the Fitbit user."""
|
||||
|
||||
display_name: str
|
||||
"""The name shown when the user's friends look at their Fitbit profile."""
|
||||
|
||||
locale: str | None
|
||||
"""The locale defined in the user's Fitbit account settings."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitConfig:
|
||||
"""Information from the fitbit ConfigEntry data."""
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.const import (
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.icon import icon_for_battery_level
|
||||
@@ -536,6 +537,8 @@ async def async_setup_entry(
|
||||
# These are run serially to reuse the cached user profile, not gathered
|
||||
# to avoid two racing requests.
|
||||
user_profile = await api.async_get_user_profile()
|
||||
if user_profile.encoded_id is None:
|
||||
raise ConfigEntryNotReady("Could not get user profile")
|
||||
unit_system = await api.async_get_unit_system()
|
||||
|
||||
fitbit_config = config_from_entry_data(entry.data)
|
||||
|
||||
@@ -14,11 +14,12 @@ from homeassistant.components.button import (
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles
|
||||
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles
|
||||
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
|
||||
from .entity import FritzDeviceBase
|
||||
from .helpers import _is_tracked
|
||||
@@ -63,10 +64,38 @@ BUTTONS: Final = [
|
||||
translation_key="cleanup",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda avm_wrapper: avm_wrapper.async_trigger_cleanup(),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None:
|
||||
"""Repair issue for cleanup button."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
if (
|
||||
(
|
||||
entity_button := entity_registry.async_get_entity_id(
|
||||
"button", DOMAIN, f"{avm_wrapper.unique_id}-cleanup"
|
||||
)
|
||||
)
|
||||
and (entity_entry := entity_registry.async_get(entity_button))
|
||||
and not entity_entry.disabled
|
||||
):
|
||||
# Deprecate the 'cleanup' button: create a Repairs issue for users
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
domain=DOMAIN,
|
||||
issue_id="deprecated_cleanup_button",
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_cleanup_button",
|
||||
translation_placeholders={"removal_version": "2026.11.0"},
|
||||
breaks_in_ha_version="2026.11.0",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FritzConfigEntry,
|
||||
@@ -82,6 +111,7 @@ async def async_setup_entry(
|
||||
|
||||
if avm_wrapper.mesh_role == MeshRoles.SLAVE:
|
||||
async_add_entities(entities_list)
|
||||
repair_issue_cleanup(hass, avm_wrapper)
|
||||
return
|
||||
|
||||
data_fritz = hass.data[FRITZ_DATA_KEY]
|
||||
@@ -100,6 +130,8 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
repair_issue_cleanup(hass, avm_wrapper)
|
||||
|
||||
|
||||
class FritzButton(ButtonEntity):
|
||||
"""Defines a Fritz!Box base button."""
|
||||
@@ -126,6 +158,12 @@ class FritzButton(ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Triggers Fritz!Box service."""
|
||||
if self.entity_description.key == "cleanup":
|
||||
_LOGGER.warning(
|
||||
"The 'cleanup' button is deprecated and will be removed in Home Assistant Core 2026.11.0. "
|
||||
"Please update your automations and dashboards to remove any usage of this button. "
|
||||
"The action is now performed automatically at each data refresh",
|
||||
)
|
||||
await self.entity_description.press_action(self.avm_wrapper)
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,5 @@ FRITZ_EXCEPTIONS = (
|
||||
|
||||
FRITZ_AUTH_EXCEPTIONS = (FritzAuthorizationError, FritzSecurityError)
|
||||
|
||||
WIFI_STANDARD = {1: "2.4Ghz", 2: "5Ghz", 3: "5Ghz", 4: "Guest"}
|
||||
|
||||
CONNECTION_TYPE_LAN = "LAN"
|
||||
|
||||
@@ -332,7 +332,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
|
||||
_LOGGER.debug("enity_data: %s", entity_data)
|
||||
_LOGGER.debug("entity_data: %s", entity_data)
|
||||
|
||||
await self.async_trigger_cleanup()
|
||||
|
||||
return entity_data
|
||||
|
||||
@property
|
||||
|
||||
@@ -10,9 +10,11 @@ from requests.exceptions import RequestException
|
||||
from homeassistant.components.image import ImageEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
|
||||
from .const import DOMAIN, Platform
|
||||
from .coordinator import AvmWrapper, FritzConfigEntry
|
||||
from .entity import FritzBoxBaseEntity
|
||||
|
||||
@@ -22,6 +24,32 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def _migrate_to_new_unique_id(
|
||||
hass: HomeAssistant, avm_wrapper: AvmWrapper, ssid: str
|
||||
) -> None:
|
||||
"""Migrate old unique id to new unique id."""
|
||||
|
||||
old_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code")
|
||||
new_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code"
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
Platform.IMAGE,
|
||||
DOMAIN,
|
||||
old_unique_id,
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
|
||||
_LOGGER.debug(
|
||||
"Migrating guest Wi-Fi image unique_id from [%s] to [%s]",
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FritzConfigEntry,
|
||||
@@ -34,6 +62,8 @@ async def async_setup_entry(
|
||||
avm_wrapper.fritz_guest_wifi.get_info
|
||||
)
|
||||
|
||||
await _migrate_to_new_unique_id(hass, avm_wrapper, guest_wifi_info["NewSSID"])
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
FritzGuestWifiQRImage(
|
||||
@@ -60,7 +90,7 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
|
||||
) -> None:
|
||||
"""Initialize the image entity."""
|
||||
self._attr_name = ssid
|
||||
self._attr_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code")
|
||||
self._attr_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code"
|
||||
self._current_qr_bytes: bytes | None = None
|
||||
super().__init__(avm_wrapper, device_friendly_name)
|
||||
ImageEntity.__init__(self, hass)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -56,9 +56,7 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: automate the current cleanup process and deprecate the corresponding button
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency:
|
||||
|
||||
@@ -169,6 +169,18 @@
|
||||
"switch": {
|
||||
"internet_access": {
|
||||
"name": "Internet access"
|
||||
},
|
||||
"wi_fi_guest": {
|
||||
"name": "Guest"
|
||||
},
|
||||
"wi_fi_main_2_4ghz": {
|
||||
"name": "Main 2.4 GHz"
|
||||
},
|
||||
"wi_fi_main_5ghz": {
|
||||
"name": "Main 5 GHz"
|
||||
},
|
||||
"wi_fi_main_5ghz_high_6ghz": {
|
||||
"name": "Main 5 GHz High / 6 GHz"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -195,6 +207,12 @@
|
||||
"message": "Error while updating the data: {error}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_cleanup_button": {
|
||||
"description": "The 'Cleanup' button is deprecated and will be removed in Home Assistant Core {removal_version}. Please update your automations and dashboards to remove any usage of this button. The action is now performed automatically at each data refresh.",
|
||||
"title": "'Cleanup' button is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.components.network import async_get_source_ip
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -22,8 +23,8 @@ from .const import (
|
||||
SWITCH_TYPE_PORTFORWARD,
|
||||
SWITCH_TYPE_PROFILE,
|
||||
SWITCH_TYPE_WIFINETWORK,
|
||||
WIFI_STANDARD,
|
||||
MeshRoles,
|
||||
Platform,
|
||||
)
|
||||
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
|
||||
from .entity import FritzBoxBaseEntity
|
||||
@@ -35,6 +36,101 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# Set a sane value to avoid too many updates
|
||||
PARALLEL_UPDATES = 5
|
||||
|
||||
WIFI_STANDARD = {1: "2.4Ghz", 2: "5Ghz", 3: "5Ghz", 4: "Guest"}
|
||||
|
||||
WIFI_BAND = {
|
||||
0: {"band": "2.4Ghz"},
|
||||
1: {"band": "5Ghz"},
|
||||
3: {"band": "5Ghz High / 6Ghz"},
|
||||
}
|
||||
|
||||
|
||||
def _wifi_naming(
|
||||
network_info: dict[str, Any], wifi_index: int, wifi_count: int
|
||||
) -> str | None:
|
||||
"""Return a friendly name for a Wi-Fi network."""
|
||||
|
||||
if wifi_index == 2 and wifi_count == 4:
|
||||
# In case of 4 Wi-Fi networks, the 2nd one is used for internal communication
|
||||
# between mesh devices and should not be named like the others to avoid confusion
|
||||
return None
|
||||
|
||||
if (wifi_index + 1) == wifi_count:
|
||||
# Last Wi-Fi network in the guest network, both bands available
|
||||
return "Guest"
|
||||
|
||||
# Cast to correct type for type checker
|
||||
if (result := WIFI_BAND.get(wifi_index)) is not None:
|
||||
return f"Main {result['band']}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _get_wifi_networks_list(avm_wrapper: AvmWrapper) -> dict[int, dict[str, Any]]:
|
||||
"""Get a list of wifi networks with friendly names."""
|
||||
wifi_count = len(
|
||||
[
|
||||
s
|
||||
for s in avm_wrapper.connection.services
|
||||
if s.startswith("WLANConfiguration")
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
for i in range(1, wifi_count + 1):
|
||||
network_info = await avm_wrapper.async_get_wlan_configuration(i)
|
||||
if (switch_name := _wifi_naming(network_info, i - 1, wifi_count)) is None:
|
||||
continue
|
||||
networks[i] = network_info
|
||||
networks[i]["switch_name"] = switch_name
|
||||
|
||||
_LOGGER.debug("WiFi networks list: %s", networks)
|
||||
return networks
|
||||
|
||||
|
||||
async def _migrate_to_new_unique_id(
|
||||
hass: HomeAssistant, avm_wrapper: AvmWrapper
|
||||
) -> None:
|
||||
"""Migrate old unique ids to new unique ids."""
|
||||
|
||||
_LOGGER.debug("Migrating Wi-Fi switches")
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
networks = await _get_wifi_networks_list(avm_wrapper)
|
||||
for index, network in networks.items():
|
||||
description = f"Wi-Fi {network['NewSSID']}"
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
):
|
||||
description += f" ({WIFI_STANDARD[index]})"
|
||||
|
||||
old_unique_id = f"{avm_wrapper.unique_id}-{slugify(description)}"
|
||||
new_unique_id = f"{avm_wrapper.unique_id}-wi_fi_{slugify(_wifi_naming(network, index - 1, len(networks)))}"
|
||||
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
Platform.SWITCH, DOMAIN, old_unique_id
|
||||
)
|
||||
|
||||
if entity_id is not None:
|
||||
entity_registry.async_update_entity(
|
||||
entity_id,
|
||||
new_unique_id=new_unique_id,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Migrating Wi-FI switch unique_id from [%s] to [%s]",
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Migration completed")
|
||||
|
||||
|
||||
async def _async_deflection_entities_list(
|
||||
avm_wrapper: AvmWrapper, device_friendly_name: str
|
||||
@@ -125,35 +221,7 @@ async def _async_wifi_entities_list(
|
||||
#
|
||||
# https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/wlanconfigSCPD.pdf
|
||||
#
|
||||
wifi_count = len(
|
||||
[
|
||||
s
|
||||
for s in avm_wrapper.connection.services
|
||||
if s.startswith("WLANConfiguration")
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
for i in range(1, wifi_count + 1):
|
||||
network_info = await avm_wrapper.async_get_wlan_configuration(i)
|
||||
# Devices with 4 WLAN services, use the 2nd for internal communications
|
||||
if not (wifi_count == 4 and i == 2):
|
||||
networks[i] = network_info
|
||||
for i, network in networks.copy().items():
|
||||
networks[i]["switch_name"] = network["NewSSID"]
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
):
|
||||
networks[i]["switch_name"] += f" ({WIFI_STANDARD[i]})"
|
||||
|
||||
_LOGGER.debug("WiFi networks list: %s", networks)
|
||||
networks = await _get_wifi_networks_list(avm_wrapper)
|
||||
return [
|
||||
FritzBoxWifiSwitch(avm_wrapper, device_friendly_name, index, data)
|
||||
for index, data in networks.items()
|
||||
@@ -225,6 +293,8 @@ async def async_setup_entry(
|
||||
|
||||
local_ip = await async_get_source_ip(avm_wrapper.hass, target_ip=avm_wrapper.host)
|
||||
|
||||
await _migrate_to_new_unique_id(hass, avm_wrapper)
|
||||
|
||||
entities_list = await async_all_entities_list(
|
||||
avm_wrapper,
|
||||
entry.title,
|
||||
@@ -554,8 +624,11 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
)
|
||||
self._network_num = network_num
|
||||
|
||||
description = f"Wi-Fi {network_data['switch_name']}"
|
||||
self._attr_translation_key = slugify(description)
|
||||
|
||||
switch_info = SwitchInfo(
|
||||
description=f"Wi-Fi {network_data['switch_name']}",
|
||||
description=description,
|
||||
friendly_name=device_friendly_name,
|
||||
icon="mdi:wifi",
|
||||
type=SWITCH_TYPE_WIFINETWORK,
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260325.5"]
|
||||
"requirements": ["home-assistant-frontend==20260325.6"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontier_silicon",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["afsapi==0.2.7"],
|
||||
"requirements": ["afsapi==0.3.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
|
||||
|
||||
@@ -4,7 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from gardena_bluetooth.const import DeviceConfiguration, Sensor, Spray, Valve
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContourWatering,
|
||||
DeviceConfiguration,
|
||||
Sensor,
|
||||
Spray,
|
||||
Valve,
|
||||
)
|
||||
from gardena_bluetooth.parse import (
|
||||
Characteristic,
|
||||
CharacteristicInt,
|
||||
@@ -58,6 +64,18 @@ DESCRIPTIONS = (
|
||||
char=Valve.manual_watering_time,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=AquaContourWatering.manual_watering_time.unique_id,
|
||||
translation_key="manual_watering_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0.0,
|
||||
native_max_value=24 * 60 * 60,
|
||||
native_step=60,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
char=AquaContourWatering.manual_watering_time,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.remaining_open_time.unique_id,
|
||||
translation_key="remaining_open_time",
|
||||
@@ -69,6 +87,17 @@ DESCRIPTIONS = (
|
||||
char=Valve.remaining_open_time,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=AquaContourWatering.remaining_watering_time.unique_id,
|
||||
translation_key="remaining_watering_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_min_value=0.0,
|
||||
native_max_value=24 * 60 * 60,
|
||||
native_step=60.0,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=AquaContourWatering.remaining_watering_time,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.rain_pause.unique_id,
|
||||
translation_key="rain_pause",
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"remaining_open_time": {
|
||||
"name": "Remaining open time"
|
||||
},
|
||||
"remaining_watering_time": {
|
||||
"name": "Remaining watering time"
|
||||
},
|
||||
"seasonal_adjust": {
|
||||
"name": "Seasonal adjust"
|
||||
},
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
"name": "Add event"
|
||||
},
|
||||
"create_event": {
|
||||
"description": "Adds a new calendar event.",
|
||||
"description": "Adds a new event to a Google calendar.",
|
||||
"fields": {
|
||||
"description": {
|
||||
"description": "[%key:component::google::services::add_event::fields::description::description%]",
|
||||
@@ -146,7 +146,7 @@
|
||||
"name": "Summary"
|
||||
}
|
||||
},
|
||||
"name": "Create event"
|
||||
"name": "Create event in Google Calendar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"name": "Filename"
|
||||
}
|
||||
},
|
||||
"name": "Upload media"
|
||||
"name": "Upload media to Google Photos"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ rules:
|
||||
comment: The integration registers no actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules:
|
||||
status: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
@@ -47,8 +46,7 @@ rules:
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: done
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
|
||||
@@ -4,8 +4,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't add actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.84", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.93", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ BSH_PROGRAM_SENSORS = (
|
||||
"CookProcessor",
|
||||
"Dishwasher",
|
||||
"Dryer",
|
||||
"Microwave",
|
||||
"Hood",
|
||||
"Oven",
|
||||
"Washer",
|
||||
@@ -198,7 +199,7 @@ EVENT_SENSORS = (
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
translation_key="program_aborted",
|
||||
appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"),
|
||||
appliance_types=("Dishwasher", "Microwave", "CleaningRobot", "CookProcessor"),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED,
|
||||
@@ -211,6 +212,7 @@ EVENT_SENSORS = (
|
||||
"Dishwasher",
|
||||
"Washer",
|
||||
"Dryer",
|
||||
"Microwave",
|
||||
"WasherDryer",
|
||||
"CleaningRobot",
|
||||
"CookProcessor",
|
||||
@@ -599,8 +601,6 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
"""Sensor class for Home Connect sensors that reports information related to the running program."""
|
||||
|
||||
program_running: bool = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register listener."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -614,18 +614,21 @@ class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
@callback
|
||||
def _handle_operation_state_event(self) -> None:
|
||||
"""Update status when an event for the entity is received."""
|
||||
self.program_running = (
|
||||
status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
|
||||
) is not None and status.value in [
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
]
|
||||
if not self.program_running:
|
||||
# reset the value when the program is not running, paused or finished
|
||||
self._attr_native_value = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def program_running(self) -> bool:
|
||||
"""Return whether a program is running, paused or finished."""
|
||||
status = self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
|
||||
return status is not None and status.value in [
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return true if the sensor is available."""
|
||||
@@ -635,13 +638,6 @@ class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Update the program sensor's status."""
|
||||
self.program_running = (
|
||||
status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
|
||||
) is not None and status.value in [
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
]
|
||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||
if event:
|
||||
self._update_native_value(event.value)
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "The integration `{domain}` could not be found. This happens when a (custom) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.",
|
||||
"description": "The integration `{domain}` could not be found. This happens when a (community) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.",
|
||||
"menu_options": {
|
||||
"confirm": "Remove previous configurations",
|
||||
"ignore": "Ignore"
|
||||
@@ -236,7 +236,7 @@
|
||||
"description": "Restarts Home Assistant.",
|
||||
"fields": {
|
||||
"safe_mode": {
|
||||
"description": "Disable custom integrations and custom cards.",
|
||||
"description": "Disable community integrations and community cards.",
|
||||
"name": "Safe mode"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -172,6 +172,8 @@ async def async_migrate_entry(
|
||||
f"USB device {device} is missing, cannot migrate"
|
||||
)
|
||||
|
||||
assert isinstance(usb_info, USBDevice)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={
|
||||
|
||||
@@ -81,5 +81,4 @@ rules:
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: Underlying huawei-lte-api does not use aiohttp or httpx, so this does not apply.
|
||||
strict-typing:
|
||||
status: done
|
||||
strict-typing: done
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohue"],
|
||||
"requirements": ["aiohue==4.8.0"],
|
||||
"requirements": ["aiohue==4.8.1"],
|
||||
"zeroconf": ["_hue._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -184,10 +184,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
)
|
||||
|
||||
def _should_poll(self) -> bool:
|
||||
"""Return True if at least one mower is connected and at least one is not OFF."""
|
||||
return any(mower.metadata.connected for mower in self.data.values()) and any(
|
||||
mower.mower.state != MowerStates.OFF for mower in self.data.values()
|
||||
)
|
||||
"""Return True if at least one mower is not OFF."""
|
||||
return any(mower.mower.state != MowerStates.OFF for mower in self.data.values())
|
||||
|
||||
async def _pong_watchdog(self) -> None:
|
||||
"""Watchdog to check for pong messages."""
|
||||
|
||||
@@ -47,11 +47,11 @@ rules:
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
|
||||
@@ -69,6 +69,10 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Get the latest data and updates the state."""
|
||||
# Guard against updates arriving after the controller has been removed
|
||||
# but before the entity has been unsubscribed from the coordinator.
|
||||
if self.controller.id not in self.coordinator.data.controllers:
|
||||
return
|
||||
self.controller = self.coordinator.data.controllers[self.controller.id]
|
||||
self._update_attrs()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hyponcloud==0.9.0"]
|
||||
"requirements": ["hyponcloud==0.9.3"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["incomfortclient"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["incomfort-client==0.6.12"]
|
||||
"requirements": ["incomfort-client==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -92,11 +92,13 @@
|
||||
"central_heating": "Central heating",
|
||||
"central_heating_low": "Central heating low",
|
||||
"central_heating_rf": "Central heating rf",
|
||||
"central_heating_wait": "Central heating waiting",
|
||||
"cv_temperature_too_high_e1": "Temperature too high",
|
||||
"flame_detection_fault_e6": "Flame detection fault",
|
||||
"frost": "Frost protection",
|
||||
"gas_valve_relay_faulty_e29": "Gas valve relay faulty",
|
||||
"gas_valve_relay_faulty_e30": "[%key:component::incomfort::entity::water_heater::boiler::state::gas_valve_relay_faulty_e29%]",
|
||||
"hp_error_recovery": "Heat pump error recovery",
|
||||
"incorrect_fan_speed_e8": "Incorrect fan speed",
|
||||
"no_flame_signal_e4": "No flame signal",
|
||||
"off": "[%key:common::state::off%]",
|
||||
@@ -120,6 +122,7 @@
|
||||
"service": "Service",
|
||||
"shortcut_outside_sensor_temperature_e27": "Shortcut outside temperature sensor",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"starting_ch": "Starting central heating",
|
||||
"tapwater": "Tap water",
|
||||
"tapwater_int": "Tap water internal",
|
||||
"unknown": "Unknown"
|
||||
|
||||
@@ -52,20 +52,13 @@ rules:
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration does not support network discovery
|
||||
docs-data-update:
|
||||
status: todo
|
||||
docs-examples:
|
||||
status: todo
|
||||
docs-known-limitations:
|
||||
status: todo
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
docs-supported-functions:
|
||||
status: todo
|
||||
docs-troubleshooting:
|
||||
status: todo
|
||||
docs-use-cases:
|
||||
status: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Integration represents a single device, not a hub with multiple devices
|
||||
@@ -73,10 +66,8 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: todo
|
||||
icon-translations:
|
||||
status: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
@@ -88,5 +79,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing:
|
||||
status: todo
|
||||
strict-typing: todo
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==2.0.3"]
|
||||
"requirements": ["pyjvcprojector==2.0.5"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["pykaleidescape==1.1.4"],
|
||||
"requirements": ["pykaleidescape==1.1.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "schemas-upnp-org:device:Basic:1",
|
||||
|
||||
@@ -2,111 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
|
||||
BaseNotificationService,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.components.notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
DOMAIN = "lannouncer"
|
||||
|
||||
ATTR_METHOD = "method"
|
||||
ATTR_METHOD_DEFAULT = "speak"
|
||||
ATTR_METHOD_ALLOWED = ["speak", "alarm"]
|
||||
|
||||
DEFAULT_PORT = 1035
|
||||
|
||||
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
}
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def get_service(
|
||||
async def async_get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> LannouncerNotificationService:
|
||||
) -> None:
|
||||
"""Get the Lannouncer notification service."""
|
||||
|
||||
@callback
|
||||
def _async_create_issue() -> None:
|
||||
"""Create issue for removed integration."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"integration_removed",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2026.3.0",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="integration_removed",
|
||||
)
|
||||
|
||||
hass.add_job(_async_create_issue)
|
||||
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
|
||||
return LannouncerNotificationService(hass, host, port)
|
||||
|
||||
|
||||
class LannouncerNotificationService(BaseNotificationService):
|
||||
"""Implementation of a notification service for Lannouncer."""
|
||||
|
||||
def __init__(self, hass, host, port):
|
||||
"""Initialize the service."""
|
||||
self._hass = hass
|
||||
self._host = host
|
||||
self._port = port
|
||||
|
||||
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to Lannouncer."""
|
||||
data = kwargs.get(ATTR_DATA)
|
||||
if data is not None and ATTR_METHOD in data:
|
||||
method = data.get(ATTR_METHOD)
|
||||
else:
|
||||
method = ATTR_METHOD_DEFAULT
|
||||
|
||||
if method not in ATTR_METHOD_ALLOWED:
|
||||
_LOGGER.error("Unknown method %s", method)
|
||||
return
|
||||
|
||||
cmd = urlencode({method: message})
|
||||
|
||||
try:
|
||||
# Open socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect((self._host, self._port))
|
||||
|
||||
# Send message
|
||||
_LOGGER.debug("Sending message: %s", cmd)
|
||||
sock.sendall(cmd.encode())
|
||||
sock.sendall(b"&@DONE@\n")
|
||||
|
||||
# Check response
|
||||
buffer = sock.recv(1024)
|
||||
if buffer != b"LANnouncer: OK":
|
||||
_LOGGER.error("Error sending data to Lannnouncer: %s", buffer.decode())
|
||||
|
||||
# Close socket
|
||||
sock.close()
|
||||
except socket.gaierror:
|
||||
_LOGGER.error("Unable to connect to host %s", self._host)
|
||||
except OSError:
|
||||
_LOGGER.exception("Failed to send data to Lannnouncer")
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"description": "The LANnouncer Android app is no longer available, so this integration has been deprecated and will be removed in a future release.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear.",
|
||||
"title": "LANnouncer integration is deprecated"
|
||||
"description": "The LANnouncer integration has been removed from Home Assistant because the LANnouncer Android app is no longer available.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear.",
|
||||
"title": "LANnouncer integration has been removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Log"
|
||||
"name": "Log activity"
|
||||
}
|
||||
},
|
||||
"title": "Activity"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The Lunatone integration."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info
|
||||
@@ -7,9 +8,10 @@ from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info
|
||||
from homeassistant.const import CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .config_flow import LunatoneConfigFlow
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import (
|
||||
LunatoneConfigEntry,
|
||||
@@ -18,9 +20,51 @@ from .coordinator import (
|
||||
LunatoneInfoDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def _update_unique_id(
|
||||
hass: HomeAssistant, entry: LunatoneConfigEntry, new_unique_id: str
|
||||
) -> None:
|
||||
_LOGGER.debug("Update unique ID")
|
||||
|
||||
# Update all associated entities
|
||||
entity_registry = er.async_get(hass)
|
||||
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
|
||||
|
||||
for entity in entities:
|
||||
parts = list(entity.unique_id.partition("-"))
|
||||
parts[0] = new_unique_id
|
||||
|
||||
entity_registry.async_update_entity(
|
||||
entity.entity_id, new_unique_id="".join(parts)
|
||||
)
|
||||
|
||||
# Update all associated devices
|
||||
device_registry = dr.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||
|
||||
for device in devices:
|
||||
identifier = device.identifiers.pop()
|
||||
parts = list(identifier[1].partition("-"))
|
||||
parts[0] = new_unique_id
|
||||
|
||||
device_registry.async_update_device(
|
||||
device.id, new_identifiers={(identifier[0], "".join(parts))}
|
||||
)
|
||||
|
||||
# Update the config entry itself
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
unique_id=new_unique_id,
|
||||
minor_version=LunatoneConfigFlow.MINOR_VERSION,
|
||||
version=LunatoneConfigFlow.VERSION,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Update of unique ID successful")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
|
||||
"""Set up Lunatone from a config entry."""
|
||||
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
|
||||
@@ -30,15 +74,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
|
||||
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
|
||||
await coordinator_info.async_config_entry_first_refresh()
|
||||
|
||||
if info_api.serial_number is None:
|
||||
if info_api.data is None or info_api.serial_number is None:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN, translation_key="missing_device_info"
|
||||
)
|
||||
|
||||
if info_api.uid is not None:
|
||||
new_unique_id = info_api.uid.replace("-", "")
|
||||
if new_unique_id != entry.unique_id:
|
||||
await _update_unique_id(hass, entry, new_unique_id)
|
||||
|
||||
assert entry.unique_id
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, str(info_api.serial_number))},
|
||||
identifiers={(DOMAIN, entry.unique_id)},
|
||||
name=info_api.name,
|
||||
manufacturer=MANUFACTURER,
|
||||
sw_version=info_api.version,
|
||||
|
||||
@@ -52,14 +52,17 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if info_api.serial_number is None:
|
||||
errors["base"] = "missing_device_info"
|
||||
else:
|
||||
await self.async_set_unique_id(str(info_api.serial_number))
|
||||
unique_id = str(info_api.serial_number)
|
||||
if info_api.uid is not None:
|
||||
unique_id = info_api.uid.replace("-", "")
|
||||
await self.async_set_unique_id(unique_id)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data_updates=data, title=url
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=url, data={CONF_URL: url})
|
||||
return self.async_create_entry(title=url, data=data)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
|
||||
@@ -41,17 +41,20 @@ async def async_setup_entry(
|
||||
coordinator_devices = config_entry.runtime_data.coordinator_devices
|
||||
dali_line_broadcasts = config_entry.runtime_data.dali_line_broadcasts
|
||||
|
||||
assert config_entry.unique_id is not None
|
||||
|
||||
entities: list[LightEntity] = [
|
||||
LunatoneLineBroadcastLight(
|
||||
coordinator_info, coordinator_devices, dali_line_broadcast
|
||||
coordinator_info,
|
||||
coordinator_devices,
|
||||
dali_line_broadcast,
|
||||
config_entry.unique_id,
|
||||
)
|
||||
for dali_line_broadcast in dali_line_broadcasts
|
||||
]
|
||||
entities.extend(
|
||||
[
|
||||
LunatoneLight(
|
||||
coordinator_devices, device_id, coordinator_info.data.device.serial
|
||||
)
|
||||
LunatoneLight(coordinator_devices, device_id, config_entry.unique_id)
|
||||
for device_id in coordinator_devices.data
|
||||
]
|
||||
)
|
||||
@@ -76,14 +79,14 @@ class LunatoneLight(
|
||||
self,
|
||||
coordinator: LunatoneDevicesDataUpdateCoordinator,
|
||||
device_id: int,
|
||||
interface_serial_number: int,
|
||||
config_entry_unique_id: str,
|
||||
) -> None:
|
||||
"""Initialize a Lunatone light."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._interface_serial_number = interface_serial_number
|
||||
self._device = self.coordinator.data[self._device_id]
|
||||
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
|
||||
self._config_entry_unique_id = config_entry_unique_id
|
||||
self._device = self.coordinator.data[device_id]
|
||||
self._attr_unique_id = f"{config_entry_unique_id}-device{device_id}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
@@ -94,7 +97,7 @@ class LunatoneLight(
|
||||
name=self._device.name,
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
f"{self._interface_serial_number}-line{self._device.data.line}",
|
||||
f"{self._config_entry_unique_id}-line{self._device.data.line}",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -179,6 +182,7 @@ class LunatoneLineBroadcastLight(
|
||||
coordinator_info: LunatoneInfoDataUpdateCoordinator,
|
||||
coordinator_devices: LunatoneDevicesDataUpdateCoordinator,
|
||||
broadcast: DALIBroadcast,
|
||||
config_entry_unique_id: str,
|
||||
) -> None:
|
||||
"""Initialize a Lunatone line broadcast light."""
|
||||
super().__init__(coordinator_info)
|
||||
@@ -187,7 +191,7 @@ class LunatoneLineBroadcastLight(
|
||||
|
||||
line = broadcast.line
|
||||
|
||||
self._attr_unique_id = f"{coordinator_info.data.device.serial}-line{line}"
|
||||
self._attr_unique_id = f"{config_entry_unique_id}-line{line}"
|
||||
|
||||
line_device = self.coordinator.data.lines[str(line)].device
|
||||
extra_info: dict = {}
|
||||
@@ -202,7 +206,7 @@ class LunatoneLineBroadcastLight(
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
name=f"DALI Line {line}",
|
||||
via_device=(DOMAIN, str(coordinator_info.data.device.serial)),
|
||||
via_device=(DOMAIN, config_entry_unique_id),
|
||||
**extra_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -4,11 +4,20 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output
|
||||
from pylutron import (
|
||||
Button,
|
||||
Keypad,
|
||||
Led,
|
||||
Lutron,
|
||||
LutronException,
|
||||
OccupancyGroup,
|
||||
Output,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -57,8 +66,12 @@ async def async_setup_entry(
|
||||
pwd = config_entry.data[CONF_PASSWORD]
|
||||
|
||||
lutron_client = Lutron(host, uid, pwd)
|
||||
await hass.async_add_executor_job(lutron_client.load_xml_db)
|
||||
lutron_client.connect()
|
||||
try:
|
||||
await hass.async_add_executor_job(lutron_client.load_xml_db)
|
||||
lutron_client.connect()
|
||||
except LutronException as ex:
|
||||
raise ConfigEntryNotReady(f"Failed to connect to Lutron repeater: {ex}") from ex
|
||||
|
||||
_LOGGER.debug("Connected to main repeater at %s", host)
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pylutron"],
|
||||
"requirements": ["pylutron==0.4.0"],
|
||||
"requirements": ["pylutron==0.4.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -23,3 +23,16 @@ ATTR_MEDIA_DESCRIPTION = "media_description"
|
||||
ATTR_LANGUAGE = "language"
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_HIDE_NOTIFICATIONS = "hide_notifications"
|
||||
|
||||
ATTR_DISPLAY_NAME = "display_name"
|
||||
ATTR_NOTE = "note"
|
||||
ATTR_AVATAR = "avatar"
|
||||
ATTR_AVATAR_MIME_TYPE = "avatar_mime_type"
|
||||
ATTR_HEADER = "header"
|
||||
ATTR_HEADER_MIME_TYPE = "header_mime_type"
|
||||
ATTR_LOCKED = "locked"
|
||||
ATTR_BOT = "bot"
|
||||
ATTR_DISCOVERABLE = "discoverable"
|
||||
ATTR_FIELDS = "fields"
|
||||
ATTR_ATTRIBUTION_DOMAINS = "attribution_domains"
|
||||
ATTR_VALUE = "value"
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
"unmute_account": {
|
||||
"service": "mdi:account-voice"
|
||||
},
|
||||
"update_profile": {
|
||||
"service": "mdi:account-edit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import timedelta
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mastodon import Mastodon
|
||||
@@ -11,11 +12,14 @@ from mastodon.Mastodon import (
|
||||
Account,
|
||||
MastodonAPIError,
|
||||
MastodonNotFoundError,
|
||||
MastodonUnauthorizedError,
|
||||
MediaAttachment,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
|
||||
from homeassistant.components import camera, image
|
||||
from homeassistant.components.media_source import async_resolve_media
|
||||
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_NAME
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
@@ -25,20 +29,34 @@ from homeassistant.core import (
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.selector import MediaSelector
|
||||
|
||||
from .const import (
|
||||
ATTR_ACCOUNT_NAME,
|
||||
ATTR_ATTRIBUTION_DOMAINS,
|
||||
ATTR_AVATAR,
|
||||
ATTR_AVATAR_MIME_TYPE,
|
||||
ATTR_BOT,
|
||||
ATTR_CONTENT_WARNING,
|
||||
ATTR_DISCOVERABLE,
|
||||
ATTR_DISPLAY_NAME,
|
||||
ATTR_DURATION,
|
||||
ATTR_FIELDS,
|
||||
ATTR_HEADER,
|
||||
ATTR_HEADER_MIME_TYPE,
|
||||
ATTR_HIDE_NOTIFICATIONS,
|
||||
ATTR_IDEMPOTENCY_KEY,
|
||||
ATTR_LANGUAGE,
|
||||
ATTR_LOCKED,
|
||||
ATTR_MEDIA,
|
||||
ATTR_MEDIA_DESCRIPTION,
|
||||
ATTR_MEDIA_WARNING,
|
||||
ATTR_NOTE,
|
||||
ATTR_STATUS,
|
||||
ATTR_VALUE,
|
||||
ATTR_VISIBILITY,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import MastodonConfigEntry
|
||||
from .utils import get_media_type
|
||||
@@ -98,6 +116,24 @@ SERVICE_POST_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_UPDATE_PROFILE = "update_profile"
|
||||
SERVICE_UPDATE_PROFILE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
|
||||
vol.Optional(ATTR_DISPLAY_NAME): str,
|
||||
vol.Optional(ATTR_NOTE): str,
|
||||
vol.Optional(ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}),
|
||||
vol.Optional(ATTR_HEADER): MediaSelector({"accept": ["image/*"]}),
|
||||
vol.Optional(ATTR_LOCKED): bool,
|
||||
vol.Optional(ATTR_BOT): bool,
|
||||
vol.Optional(ATTR_DISCOVERABLE): bool,
|
||||
vol.Optional(ATTR_FIELDS): vol.All(
|
||||
cv.ensure_list, vol.Length(max=4), [dict[str, str]]
|
||||
),
|
||||
vol.Optional(ATTR_ATTRIBUTION_DOMAINS): vol.All(cv.ensure_list, [str]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
@@ -124,6 +160,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_POST, _async_post, schema=SERVICE_POST_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_PROFILE,
|
||||
_async_update_profile,
|
||||
schema=SERVICE_UPDATE_PROFILE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
async def _async_account_lookup(
|
||||
@@ -319,3 +362,71 @@ def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None:
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_send_message",
|
||||
) from err
|
||||
|
||||
|
||||
async def _async_update_profile(call: ServiceCall) -> ServiceResponse:
|
||||
"""Update profile information."""
|
||||
params = dict(call.data.copy())
|
||||
|
||||
entry: MastodonConfigEntry = service.async_get_config_entry(
|
||||
call.hass, DOMAIN, params.pop(ATTR_CONFIG_ENTRY_ID)
|
||||
)
|
||||
client = entry.runtime_data.client
|
||||
|
||||
if avatar := params.pop(ATTR_AVATAR, None):
|
||||
params[ATTR_AVATAR], params[ATTR_AVATAR_MIME_TYPE] = await _resolve_media(
|
||||
call.hass, avatar
|
||||
)
|
||||
if header := params.pop(ATTR_HEADER, None):
|
||||
params[ATTR_HEADER], params[ATTR_HEADER_MIME_TYPE] = await _resolve_media(
|
||||
call.hass, header
|
||||
)
|
||||
if fields := params.get(ATTR_FIELDS):
|
||||
params[ATTR_FIELDS] = [
|
||||
(field[ATTR_NAME].strip(), field[ATTR_VALUE].strip())
|
||||
for field in fields
|
||||
if field[ATTR_NAME].strip()
|
||||
]
|
||||
try:
|
||||
return await call.hass.async_add_executor_job(
|
||||
lambda: client.account_update_credentials(**params)
|
||||
)
|
||||
except MastodonUnauthorizedError as error:
|
||||
entry.async_start_reauth(call.hass)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from error
|
||||
except MastodonAPIError as err:
|
||||
LOGGER.debug("Full exception:", exc_info=err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_update_profile",
|
||||
) from err
|
||||
|
||||
|
||||
async def _resolve_media(
|
||||
hass: HomeAssistant, media_source: dict[str, str]
|
||||
) -> tuple[bytes | Path, str | None]:
|
||||
"""Resolve media from a media source."""
|
||||
media_content_id: str = media_source["media_content_id"]
|
||||
if media_content_id.startswith("media-source://camera/"):
|
||||
entity_id = media_content_id.removeprefix("media-source://camera/")
|
||||
snapshot = await camera.async_get_image(hass, entity_id)
|
||||
return snapshot.content, snapshot.content_type
|
||||
|
||||
if media_content_id.startswith("media-source://image/"):
|
||||
entity_id = media_content_id.removeprefix("media-source://image/")
|
||||
img = await image.async_get_image(hass, entity_id)
|
||||
return img.content, img.content_type
|
||||
|
||||
media = await async_resolve_media(hass, media_source["media_content_id"], None)
|
||||
|
||||
if media.path is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="media_source_not_supported",
|
||||
translation_placeholders={"media_content_id": media_content_id},
|
||||
)
|
||||
|
||||
return media.path, media.mime_type
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
get_account:
|
||||
fields:
|
||||
config_entry_id:
|
||||
config_entry_id: &config_entry_id
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
@@ -11,11 +11,7 @@ get_account:
|
||||
text:
|
||||
mute_account:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: mastodon
|
||||
config_entry_id: *config_entry_id
|
||||
account_name:
|
||||
required: true
|
||||
selector:
|
||||
@@ -32,22 +28,14 @@ mute_account:
|
||||
boolean:
|
||||
unmute_account:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: mastodon
|
||||
config_entry_id: *config_entry_id
|
||||
account_name:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
post:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: mastodon
|
||||
config_entry_id: *config_entry_id
|
||||
status:
|
||||
required: true
|
||||
selector:
|
||||
@@ -282,3 +270,55 @@ post:
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
update_profile:
|
||||
fields:
|
||||
config_entry_id: *config_entry_id
|
||||
display_name:
|
||||
selector:
|
||||
text:
|
||||
note:
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
avatar:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- "image/*"
|
||||
header:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- "image/*"
|
||||
locked:
|
||||
selector:
|
||||
boolean:
|
||||
bot:
|
||||
selector:
|
||||
boolean:
|
||||
discoverable:
|
||||
selector:
|
||||
boolean:
|
||||
fields:
|
||||
selector:
|
||||
object:
|
||||
label_field: "value"
|
||||
description_field: "name"
|
||||
multiple: true
|
||||
translation_key: fields
|
||||
fields:
|
||||
name:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
value:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
attribution_domains:
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
type: url
|
||||
|
||||
@@ -104,6 +104,9 @@
|
||||
"idempotency_key_too_short": {
|
||||
"message": "Idempotency key must be at least 4 characters long."
|
||||
},
|
||||
"media_source_not_supported": {
|
||||
"message": "Media source {media_content_id} is not supported."
|
||||
},
|
||||
"mute_duration_too_long": {
|
||||
"message": "Mute duration is too long."
|
||||
},
|
||||
@@ -122,11 +125,26 @@
|
||||
"unable_to_unmute_account": {
|
||||
"message": "Unable to unmute account \"{account_name}\""
|
||||
},
|
||||
"unable_to_update_profile": {
|
||||
"message": "Unable to update profile."
|
||||
},
|
||||
"unable_to_upload_image": {
|
||||
"message": "Unable to upload image {media_path}."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"fields": {
|
||||
"fields": {
|
||||
"name": {
|
||||
"description": "The label for this field.",
|
||||
"name": "Label"
|
||||
},
|
||||
"value": {
|
||||
"description": "The value for this field.",
|
||||
"name": "Value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post_visibility": {
|
||||
"options": {
|
||||
"direct": "Direct - Mentioned accounts only",
|
||||
@@ -228,6 +246,52 @@
|
||||
}
|
||||
},
|
||||
"name": "Unmute account"
|
||||
},
|
||||
"update_profile": {
|
||||
"description": "Updates your Mastodon profile information and pictures.",
|
||||
"fields": {
|
||||
"attribution_domains": {
|
||||
"description": "Websites allowed to credit you. Protects from false attributions. Note that setting attribution domains will replace all existing attribution domains, not just the ones specified here.",
|
||||
"name": "Attribution domains"
|
||||
},
|
||||
"avatar": {
|
||||
"description": "An image to set as your profile picture. WEBP, PNG, or JPG. At most 8 MB. Will be downscaled to 400x400px.",
|
||||
"name": "Profile picture"
|
||||
},
|
||||
"bot": {
|
||||
"description": "Signal to others that the account mainly performs automated actions.",
|
||||
"name": "Automated account"
|
||||
},
|
||||
"config_entry_id": {
|
||||
"description": "Select the Mastodon account to update the profile of.",
|
||||
"name": "[%key:component::mastodon::services::post::fields::config_entry_id::name%]"
|
||||
},
|
||||
"discoverable": {
|
||||
"description": "Whether your profile should be discoverable. Public posts and the profile may be featured or recommended across Mastodon.",
|
||||
"name": "Discoverable"
|
||||
},
|
||||
"display_name": {
|
||||
"description": "The display name to set on your profile.",
|
||||
"name": "Display name"
|
||||
},
|
||||
"fields": {
|
||||
"description": "Additional profile fields as key-value pairs. Your homepage, pronouns, age, anything you want. Note that updating fields will replace all existing fields, not just the ones specified here.",
|
||||
"name": "Extra fields"
|
||||
},
|
||||
"header": {
|
||||
"description": "An image to set as your profile header. WEBP, PNG, or JPG. At most 8 MB. Will be downscaled to 1500x500px.",
|
||||
"name": "Header picture"
|
||||
},
|
||||
"locked": {
|
||||
"description": "Whether to lock your profile. A locked profile requires you to approve followers and hides your posts from non-followers.",
|
||||
"name": "Lock profile"
|
||||
},
|
||||
"note": {
|
||||
"description": "The bio to set on your profile. You can @mention other people or #hashtags.",
|
||||
"name": "Bio"
|
||||
}
|
||||
},
|
||||
"name": "Update profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
(0x1209, 0x8027),
|
||||
(0x1209, 0x8028),
|
||||
(0x1209, 0x8029),
|
||||
(0x138C, 0x0101),
|
||||
}
|
||||
|
||||
SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
@@ -172,6 +173,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
(0x1209, 0x8028),
|
||||
(0x1209, 0x8029),
|
||||
(0x131A, 0x1000),
|
||||
(0x138C, 0x0101),
|
||||
}
|
||||
|
||||
SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum
|
||||
|
||||
@@ -323,7 +323,11 @@ DISCOVERY_SCHEMAS = [
|
||||
required_attributes=(
|
||||
clusters.FanControl.Attributes.FanMode,
|
||||
clusters.FanControl.Attributes.PercentCurrent,
|
||||
clusters.FanControl.Attributes.PercentSetting,
|
||||
),
|
||||
# PercentSetting SHALL be null when FanMode is Auto (spec 4.4.6.3),
|
||||
# so allow null values to not block discovery in that state.
|
||||
allow_none_value=True,
|
||||
optional_attributes=(
|
||||
clusters.FanControl.Attributes.SpeedSetting,
|
||||
clusters.FanControl.Attributes.RockSetting,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user