Compare commits

..

14 Commits

Author SHA1 Message Date
Paulus Schoutsen 77fe61db3e Load the llm integration via the conversation dependency
The Assist API's built-in tools (date/time, calendar, todo, script, live
context, intents) now come from the llm integration's tool platform, so the
llm integration must be loaded for an Assist LLM API to expose them. Add llm
to conversation's dependencies so it loads wherever the conversation stack
(and thus any LLM agent) is active.

This makes the registry tools reach Assist consumers, which surfaces in
anthropic's test_extended_thinking_tool_call: it patched
AssistAPI._async_get_tools to control the tool set, but tools now also come
from the registry. Empty the registry in that test so its tool set stays the
single mock tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:05:14 -04:00
Paulus Schoutsen ac3a1493aa Register intent LLM tools from the llm integration
Relocate the intent-tool wrapping (and its two prompt fragments,
DEVICE_CONTROL_TOOL_USAGE_PROMPT and the no-timer note) out of AssistAPI
into a core provider registered by the llm integration. This is a
behavior-preserving relocation: every intent that was wrapped before is
still wrapped, with byte-identical tool names and prompt lines.

The new homeassistant/components/llm/intents.py owns IGNORE_INTENTS, the
timer-intent set, the slugify cache and the device-control prompt, and
registers an intent_tools provider in the llm integration's async_setup
(before GetDateTime/live-context so the tool order is unchanged). The
provider reproduces AssistAPI's exposure/timer filtering exactly and only
emits its prompt when entities are actually exposed.

AssistAPI._async_get_tools now returns [] and the api_prompt no longer
hardcodes the device-control or no-timer fragments; the now-unused imports
and helpers are removed. The order-independent parity snapshot
(test_assist_api_snapshot) stays green without regeneration.

llm gains an after_dependencies on intent (its provider uses intent
component helpers, which are safe without intent being set up). mcp_server,
which consumes the assist API directly, now depends on llm so its
registered tools are available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:56:01 -04:00
Paulus Schoutsen fbda9f4fc3 Set up llm integration in dynamic time injection test
test_dynamic_time_injection case 3 relies on the Assist API providing the
GetDateTime tool (which suppresses the dynamic time prompt). Since that tool
now comes from the llm integration's registry rather than being hardcoded in
AssistAPI, the test must set up the llm integration; otherwise GetDateTime is
absent and the time prompt is injected, failing the assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:32:39 -04:00
Paulus Schoutsen 5802cada22 Register live-context LLM tool from the llm integration
Move GetLiveContextTool and its DYNAMIC_CONTEXT_PROMPT prompt fragment out of
AssistAPI into the llm integration. The tool is now contributed by a registered
provider (with its prompt via LLMTools(prompt=...)) that returns it only when
entities are exposed, reproducing the previous exposure-dependent behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:01:03 -04:00
Paulus Schoutsen f357dd3d49 Await tool platform discovery in calendar/todo LLM tests
test_calendar_get_events_tool and test_todo_get_items_tool set up their
integration last and then called async_get_api before the llm integration's
EVENT_COMPONENT_LOADED-driven platform discovery had registered the provider,
so they passed in the full-file run (later setups cycled the loop) but failed
in isolation / under randomized ordering. Add async_block_till_done() before
async_get_api so discovery completes deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:53:03 -04:00
Paulus Schoutsen 1eca57a01b Register script LLM tools from the script platform
Move ScriptTool (and its ActionTool base, the _get_cached_action_parameters
helper and the action-parameters cache it owns) out of AssistAPI into a
script/llm.py tool platform discovered by the llm integration, mirroring
calendar/llm.py and todo/llm.py. ActionTool was used exclusively by
ScriptTool, so the whole unit moves together; helpers/llm.py keeps only the
SCRIPT_DOMAIN import it still needs for _get_exposed_entities.

The provider reuses llm.async_get_exposed_entities to reproduce the exact
exposed-script entities AssistAPI fed the tool, keeping the emitted
ScriptTools byte-identical. The parity snapshot stays unchanged: script was
already set up in test_assist_api_snapshot, so the platform is discovered
without surfacing any new tools.

The ScriptTool tests in test_llm.py now set up the llm component (so the
platform is discovered) and reference the tool and cache via the script
component root (script.llm) to satisfy the component-root import rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:44:14 -04:00
Paulus Schoutsen 0dde553653 Register todo LLM tool from the todo platform
Move TodoGetItemsTool out of AssistAPI into a todo/llm.py tool platform
discovered by the llm integration, mirroring calendar/llm.py. The provider
reuses llm.async_get_exposed_entities to reproduce the exact exposed
to-do list names AssistAPI fed the tool, keeping todo_get_items
byte-identical.

The parity snapshot now sets up the todo component (required to load the
platform), which also registers todo's intent handlers. This additively
surfaces the HassListAddItem/HassListCompleteItem/HassListRemoveItem
intent tools in the snapshot; the moved todo_get_items tool and the
prompt are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:30:17 -04:00
Paulus Schoutsen 5943b70577 Register calendar LLM tool from the calendar platform
Move CalendarGetEventsTool out of AssistAPI into a calendar/llm.py tool
platform discovered by the llm integration. Add a public
async_get_exposed_entities helper so the provider reproduces the exact
exposed-calendar names AssistAPI fed the tool, keeping the parity snapshot
byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:19:51 -04:00
Paulus Schoutsen 99ee92029d Register GetDateTime LLM tool from the llm integration
Move the GetDateTimeTool class out of AssistAPI's hardcoded built-in list
and register it from the llm integration's async_setup via the tool
registry. Net behavior is unchanged: GetDateTime is still exposed by the
Assist API, it just comes from the registry now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:07:45 -04:00
Paulus Schoutsen 717acdf771 Make Assist API parity snapshot order-independent
The v1 tool-platform refactor moves built-in tools and prompt fragments out
of AssistAPI into per-integration registrations, which changes their order
but not their content. Compare the tool set as a name-keyed mapping and the
prompt as a set of lines, so each migration step verifies same-tools /
same-content / same-prompt regardless of ordering. Final ordering parity with
dev is checked separately at the end of the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:01:59 -04:00
Paulus Schoutsen e76d83241e Source AssistAPI tools and prompt from the tool registry
AssistAPI.async_get_api_instance now also pulls registered tool
providers for the assist API, appending their tools and prompt
fragments to the hardcoded built-ins. Purely additive: with no
registrations, behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:43:41 -04:00
Paulus Schoutsen 6024f96d56 Add llm integration with tool platform discovery
New system integration that owns the LLM tools platform: its async_setup
drives async_process_integration_platforms(hass, "llm", ...) so integrations
can ship an <integration>/llm.py with an async_setup_tools hook to register
tools, mirroring the intent helper/integration split. The framework (registry,
Tool, APIs) stays in homeassistant.helpers.llm. No tools are registered yet,
so behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:39:05 -04:00
Paulus Schoutsen 5eec6167cc Add LLM tool registration API and registry
Introduce a per-tool registration layer in helpers/llm.py: an LLMTools
result (tools + optional prompt fragment), a provider callback type, and
async_register_tool_provider / async_register_tool registering into one or
more API ids. Providers are stored in a registry and merged by
_async_get_registered_tools. Nothing consumes the registry yet, so there is
no behavior change (the Assist parity snapshot is unchanged); AssistAPI is
wired to it in a later step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:22:31 -04:00
Paulus Schoutsen 184025cfb4 Add Assist API behavior-parity snapshot test
Capture the assembled Assist prompt and the full serialized tool set
(name, description, parameters) for a scenario exercising every built-in
tool type — intents, timer tools, calendar/todo/script tools, GetDateTime
and GetLiveContext. This is the parity net for the upcoming LLM tool
platform refactor: the snapshot must stay identical as tools and intents
move out of AssistAPI into per-integration platforms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:19:09 -04:00
663 changed files with 4566 additions and 22573 deletions
+1 -3
View File
@@ -6,7 +6,6 @@
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
# GitHub Copilot & Claude Code Instructions
@@ -51,5 +50,4 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
+2 -2
View File
@@ -193,7 +193,7 @@ jobs:
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Build base image
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
@@ -264,7 +264,7 @@ jobs:
fi
- name: Build machine image
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
@@ -50,24 +50,19 @@ jobs:
check-latest: true
- name: Install script dependencies
run: pip install -r script/check_requirements/requirements.txt
- name: Collect PR diff and head SHA
id: pr
- name: Collect PR diff
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
run: |
mkdir -p deterministic
gh pr diff "${PR_NUMBER}" > deterministic/pr.diff
HEAD_SHA=$(gh pr view "${PR_NUMBER}" --json headRefOid --jq '.headRefOid')
echo "head_sha=${HEAD_SHA}" >> "${GITHUB_OUTPUT}"
- name: Run deterministic checks
env:
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
python -m script.check_requirements \
--pr-number "${PR_NUMBER}" \
--head-sha "${HEAD_SHA}" \
--diff deterministic/pr.diff \
--output deterministic/results.json
- name: Upload deterministic-results artifact
+3 -77
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7b142e96e0f8b454cdcc9c0c25070cf9a52c44d83a6b1fbc3ad6725b6567337c","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# ___ _ _
# / _ \ | | (_)
@@ -345,7 +345,6 @@ jobs:
needs:
- activation
- extract_pr_number
- gate
if: needs.activation.outputs.daily_effective_workflow_exceeded != 'true'
runs-on: ubuntu-latest
permissions:
@@ -995,7 +994,6 @@ jobs:
- agent
- detection
- extract_pr_number
- gate
- safe_outputs
if: >
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
@@ -1430,8 +1428,8 @@ jobs:
}
extract_pr_number:
needs: gate
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
needs: activation
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
@@ -1462,78 +1460,6 @@ jobs:
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
gate:
needs: activation
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
run: |
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
GH_HOST="${GITHUB_SERVER_URL#https://}"
GH_HOST="${GH_HOST#http://}"
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
name: check-requirements-deterministic
path: /tmp/gate
run-id: ${{ github.event.workflow_run.id }}
- name: Decide whether requirements changed since the last comment
id: gate
run: |
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
if [ -z "${HEAD}" ]; then
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check
# comment from the "Checked at commit" link
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oiE '/commit/[0-9a-f]{40}' \
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
fi
if [ "${PRIOR}" = "${HEAD}" ]; then
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
# List files changed between the recorded commit and the current head.
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
--jq '.files[].filename' 2>/dev/null) || {
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
exit 0
}
TRACKED=$(printf '%s\n' "${CHANGED}" \
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
if [ -z "${TRACKED}" ]; then
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
else
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
printf '%s\n' "${TRACKED}"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pre_activation:
runs-on: ubuntu-slim
outputs:
+1 -62
View File
@@ -22,69 +22,8 @@ safe-outputs:
needs:
- extract_pr_number
jobs:
gate:
# Skip the (token-spending) agent when no tracked requirement file changed
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: check-requirements-deterministic
path: /tmp/gate
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Decide whether requirements changed since the last comment
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
if [ -z "${HEAD}" ]; then
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check
# comment from the "Checked at commit" link
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oiE '/commit/[0-9a-f]{40}' \
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
fi
if [ "${PRIOR}" = "${HEAD}" ]; then
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
# List files changed between the recorded commit and the current head.
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
--jq '.files[].filename' 2>/dev/null) || {
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
exit 0
}
TRACKED=$(printf '%s\n' "${CHANGED}" \
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
if [ -z "${TRACKED}" ]; then
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
else
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
printf '%s\n' "${TRACKED}"
fi
extract_pr_number:
needs: gate
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
+1 -1
View File
@@ -102,7 +102,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
+1 -2
View File
@@ -40,5 +40,4 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
Generated
+5 -5
View File
@@ -262,8 +262,8 @@ CLAUDE.md @home-assistant/core
/tests/components/braviatv/ @bieniu @Drafteed
/homeassistant/components/bring/ @miaucl @tr4nt0r
/tests/components/bring/ @miaucl @tr4nt0r
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/homeassistant/components/brother/ @bieniu
/tests/components/brother/ @bieniu
/homeassistant/components/brottsplatskartan/ @gjohansson-ST
@@ -695,8 +695,6 @@ CLAUDE.md @home-assistant/core
/tests/components/gree/ @cmroche
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greencell/ @BrzezowskiGC
/tests/components/greencell/ @BrzezowskiGC
/homeassistant/components/greeneye_monitor/ @jkeljo
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
@@ -1024,6 +1022,8 @@ CLAUDE.md @home-assistant/core
/tests/components/litterrobot/ @natekspencer @tkdrob
/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet
/tests/components/livisi/ @StefanIacobLivisi @planbnet
/homeassistant/components/llm/ @home-assistant/core
/tests/components/llm/ @home-assistant/core
/homeassistant/components/local_calendar/ @allenporter
/tests/components/local_calendar/ @allenporter
/homeassistant/components/local_ip/ @issacg
@@ -1157,6 +1157,7 @@ CLAUDE.md @home-assistant/core
/tests/components/motionmount/ @laiho-vogels
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mta/ @OnFreund
/tests/components/mta/ @OnFreund
/homeassistant/components/mullvad/ @meichthys
@@ -1892,7 +1893,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/tests/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifi_discovery/ @RaHehl
/tests/components/unifi_discovery/ @RaHehl
/homeassistant/components/unifiled/ @florisvdk
+1
View File
@@ -11,6 +11,7 @@
"microsoft_face_identify",
"microsoft_face",
"microsoft",
"msteams",
"onedrive",
"onedrive_for_business",
"xbox"
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.18"]
"requirements": ["aioacaia==0.1.17"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.8.2"]
"requirements": ["serialx==1.8.0"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/acmeda",
"iot_class": "local_push",
"loggers": ["aiopulse"],
"requirements": ["aiopulse==0.4.7"]
"requirements": ["aiopulse==0.4.6"]
}
+1 -1
View File
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
"requirements": ["aioairq==0.4.8"],
"requirements": ["aioairq==0.4.7"],
"zeroconf": [
{
"properties": {
@@ -37,9 +37,6 @@
"title": "Re-authenticate AirVisual"
},
"user": {
"data": {
"type": "Integration type"
},
"description": "Pick what type of AirVisual data you want to monitor.",
"title": "Configure AirVisual"
}
@@ -1,6 +1,10 @@
"""The AirVisual Pro integration."""
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -28,7 +32,7 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
connections={
(
CONNECTION_NETWORK_MAC,
self.coordinator.data["status"]["mac_address"],
format_mac(self.coordinator.data["status"]["mac_address"]),
)
},
manufacturer="AirVisual",
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.4"]
"requirements": ["aioamazondevices==14.0.3"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.17.1"]
"requirements": ["anova-wifi==0.17.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["anthemav"],
"requirements": ["anthemav==1.4.2"]
"requirements": ["anthemav==1.4.1"]
}
@@ -12,7 +12,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_MAC, CONF_MODEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -87,12 +87,9 @@ class AnthemAVR(MediaPlayerEntity):
via_device=(DOMAIN, mac_address),
)
else:
# Zone 1 is the physical receiver that owns the network MAC; higher
# zones are via_device children and carry no connection.
self._attr_unique_id = mac_address
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac_address)},
connections={(CONNECTION_NETWORK_MAC, mac_address)},
name=name,
manufacturer=MANUFACTURER,
model=model,
@@ -52,7 +52,10 @@ rules:
status: exempt
comment: |
Service integration, no discovery.
docs-data-update: done
docs-data-update:
status: exempt
comment: |
No data updates.
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
@@ -193,7 +193,6 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
device_info = DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
connections={(dr.CONNECTION_NETWORK_MAC, data[Attribute.MAC_ADDRESS])},
name=self.create_device_name(data),
manufacturer="Aprilaire",
)
@@ -1,6 +1,5 @@
"""Config flow for Aquacell integration."""
from collections.abc import Mapping
from datetime import datetime
import logging
from typing import Any
@@ -32,12 +31,6 @@ DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aquacell."""
@@ -84,48 +77,3 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=DATA_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
session = async_get_clientsession(self.hass)
api = AquacellApi(
session, reauth_entry.data.get(CONF_BRAND, Brand.AQUACELL)
)
try:
refresh_token = await api.authenticate(
reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except ApiException, TimeoutError:
errors["base"] = "cannot_connect"
except AuthenticationFailed:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_SCHEMA,
description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
errors=errors,
)
@@ -14,7 +14,7 @@ from aioaquacell import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
@@ -79,7 +79,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
softeners = await self.aquacell_api.get_all_softeners()
except AuthenticationFailed as err:
raise ConfigEntryAuthFailed from err
raise ConfigEntryError from err
except (AquacellApiException, TimeoutError) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -10,13 +9,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"description": "The password for {email} is no longer valid. Enter your current softener mobile app password to reconnect.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"brand": "Brand",
@@ -59,9 +59,7 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
self._get_reconfigure_entry(), data_updates=user_input
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=account_data.name or "Aqvify", data=user_input
)
return self.async_create_entry(title="Aqvify", data=user_input)
return self.async_show_form(
step_id="user",
+2 -26
View File
@@ -12,7 +12,6 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -50,7 +49,6 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
self.api_client = AqvifyAPI(
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
)
self.previous_devices: set[str] = set()
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -104,25 +102,10 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
},
) from err
current_devices = set(devices.devices.keys())
if stale_devices := self.previous_devices - current_devices:
account_id = self.config_entry.unique_id
device_registry = dr.async_get(self.hass)
for device_id in stale_devices:
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{account_id}_{device_id}")}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_devices = current_devices
device_data = {}
for aqvify_device in devices.devices.values():
for device in devices.devices.values():
try:
device_key = str(aqvify_device.device_key)
device_key = str(device.device_key)
device_data[
device_key
] = await self.api_client.async_get_device_latest_data(device_key)
@@ -152,10 +135,3 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
devices=devices,
device_data=device_data,
)
def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]:
"""Return newly discovered device keys and the full current device set."""
current_devices = set(self.data.devices.devices)
new_devices: set[str] = current_devices - added_devices
return (new_devices, current_devices)
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "silver",
"requirements": ["pyaqvify==0.0.11"]
"quality_scale": "bronze",
"requirements": ["pyaqvify==0.0.9"]
}
@@ -29,28 +29,16 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
The integration does not provide any actions.
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
There are no configuration options.
docs-installation-parameters: done
entity-unavailable:
status: done
comment: |
Handled by coordinator.
integration-owner: done
log-when-unavailable:
status: done
comment: |
Handled by coordinator.
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
+6 -35
View File
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength, UnitOfTemperature, UnitOfVolume
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -50,23 +50,6 @@ ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
suggested_display_precision=2,
value_fn=lambda value: value.water_level,
),
AqvifySensorEntityDescription(
key="volume",
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_STORAGE,
suggested_display_precision=0,
value_fn=lambda value: value.volume,
),
AqvifySensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
value_fn=lambda value: value.temperature,
entity_registry_enabled_default=False,
),
)
@@ -76,23 +59,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aqvify sensor entities from a config entry."""
coordinator = entry.runtime_data
added_devices: set[str] = set()
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
AqvifySensor(coordinator, description, device_key)
for description in ENTITIES
for device_key in new_devices_set
)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
async_add_entities(
AqvifySensor(entry.runtime_data, description, device_key)
for description in ENTITIES
for device_key in entry.runtime_data.data.devices.devices
)
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["atenpdu==0.3.6"]
"requirements": ["atenpdu==0.3.2"]
}
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.1"]
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
}
+34 -54
View File
@@ -17,7 +17,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import get_maybe_authenticated_session
@@ -76,21 +75,6 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"address": f"{host}:{port}"},
)
async def _async_box_from_host_or_abort(
self, api_host: ApiHost
) -> Box | ConfigFlowResult:
"""Try to connect to the device; return product or an abort result."""
try:
return await Box.async_from_host(api_host)
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except Error:
return self.async_abort(reason="cannot_connect")
async def _async_from_host_or_form(
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
@@ -117,50 +101,45 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
)
async def _async_handle_discovery(self, host: str, port: int) -> ConfigFlowResult:
"""Handle discovery by IP and port; probe device then confirm with the user."""
self.device_config["host"] = host
self.device_config["port"] = port
websession = async_get_clientsession(self.hass)
api_host = ApiHost(
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
result = await self._async_box_from_host_or_abort(api_host)
if not isinstance(result, Box):
return result
product = result
self.device_config["name"] = product.name
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self.context.update(
{
"title_placeholders": {
"name": self.device_config["name"],
"host": host,
},
"configuration_url": f"http://{host}",
}
)
return await self.async_step_confirm_discovery()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
return await self._async_handle_discovery(discovery_info.ip, DEFAULT_PORT)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
return await self._async_handle_discovery(
discovery_info.host, discovery_info.port or DEFAULT_PORT
hass = self.hass
ipaddress = (discovery_info.host, discovery_info.port)
self.device_config["host"] = discovery_info.host
self.device_config["port"] = discovery_info.port
websession = async_get_clientsession(hass)
api_host = ApiHost(
*ipaddress, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
)
try:
product = await Box.async_from_host(api_host)
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
self.device_config["name"] = product.name
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
self.context.update(
{
"title_placeholders": {
"name": self.device_config["name"],
"host": self.device_config["host"],
},
"configuration_url": f"http://{discovery_info.host}",
}
)
return await self.async_step_confirm_discovery()
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -179,6 +158,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={
"name": self.device_config["name"],
"host": self.device_config["host"],
"port": self.device_config["port"],
},
)
@@ -3,45 +3,6 @@
"name": "BleBox devices",
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
"config_flow": true,
"dhcp": [
{ "hostname": "rollergate*" },
{ "hostname": "gatebox*" },
{ "hostname": "doorbox*" },
{ "hostname": "shutterbox*" },
{ "hostname": "switchbox*" },
{ "hostname": "dimmerbox*" },
{ "hostname": "dacbox*" },
{ "hostname": "wlightbox*" },
{ "hostname": "pixelbox*" },
{ "hostname": "saunabox*" },
{ "hostname": "thermobox*" },
{ "hostname": "tempsensor*" },
{ "hostname": "energymeter*" },
{ "hostname": "airsensor*" },
{ "hostname": "humiditysensor*" },
{ "hostname": "rainsensor*" },
{ "hostname": "floodsensor*" },
{ "hostname": "luxsensor*" },
{ "hostname": "inputsensor*" },
{ "hostname": "opensensor*" },
{ "hostname": "windsensor*" },
{ "hostname": "co2sensor*" },
{ "hostname": "simongo*" },
{ "hostname": "sabaj-k-smrt*" },
{ "hostname": "rico*" },
{ "hostname": "smartrollergate*" },
{ "hostname": "darco_ero_32ws_0*" },
{ "hostname": "pergoladc*" },
{ "hostname": "seltsmartscreen*" },
{ "hostname": "seltvenetianblind*" },
{ "hostname": "doorunitbox*" },
{ "hostname": "drutexsmart*" },
{ "hostname": "swingatecontroller*" },
{ "hostname": "windowopener*" },
{ "hostname": "smartawning*" },
{ "hostname": "smartshade*" },
{ "hostname": "smartshutter*" }
],
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",
"iot_class": "local_polling",
@@ -4,7 +4,6 @@
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"authorization_required": "The BleBox device requires authentication.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device identifier does not match the previously configured device.",
@@ -19,10 +18,6 @@
},
"flow_title": "{name} ({host})",
"step": {
"confirm_discovery": {
"description": "Do you want to add the BleBox device **{name}** at `{host}` to Home Assistant?",
"title": "BleBox device discovered"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
+1 -1
View File
@@ -21,5 +21,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["blinkpy"],
"requirements": ["blinkpy==0.25.6"]
"requirements": ["blinkpy==0.25.2"]
}
@@ -26,12 +26,6 @@
"description": "The credentials for {username} need to be updated",
"title": "Re-authenticate Blink"
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["bluecurrent_api"],
"requirements": ["bluecurrent-api==1.3.3"]
"requirements": ["bluecurrent-api==1.3.2"]
}
+1 -1
View File
@@ -105,7 +105,7 @@ class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity):
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyblu==2.0.8"],
"requirements": ["pyblu==2.0.6"],
"zeroconf": [
{
"type": "_musc._tcp.local."
@@ -118,7 +118,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
@@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> boo
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, shc_info.unique_id)},
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(shc_info.unique_id))},
identifiers={(DOMAIN, shc_info.unique_id)},
manufacturer="Bosch",
name=entry.title,
@@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["boschshcpy"],
"requirements": ["boschshcpy==0.2.111"],
"requirements": ["boschshcpy==0.2.107"],
"zeroconf": [
{
"name": "bosch shc*",
@@ -123,14 +123,7 @@ class _BrandsBaseView(HomeAssistantView):
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
async def _serve_from_custom_integration(
+1 -8
View File
@@ -118,14 +118,7 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
return False
except (NetworkTimeoutError, OSError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connect_failed",
translation_placeholders={
"host": api.host[0],
"error": str(err),
},
) from err
raise ConfigEntryNotReady from err
except BroadlinkException as err:
_LOGGER.error(
@@ -1,7 +1,7 @@
{
"domain": "broadlink",
"name": "Broadlink",
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"],
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
"config_flow": true,
"dhcp": [
{
@@ -89,9 +89,6 @@
}
},
"exceptions": {
"connect_failed": {
"message": "Failed to connect to the device at {host}: {error}"
},
"frequency_not_supported": {
"message": "Broadlink devices cannot transmit on {frequency} MHz"
},
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["evolutionhttp==0.0.19"]
"requirements": ["evolutionhttp==0.0.18"]
}
+6 -2
View File
@@ -31,7 +31,11 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -71,7 +75,7 @@ def get_bsblan_device_info(
"""Build DeviceInfo for the main BSB-LAN controller device."""
return DeviceInfo(
identifiers={(DOMAIN, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
name=device.name,
manufacturer="BSBLAN Inc.",
model=(
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["buienradar", "vincenty"],
"requirements": ["buienradar==1.0.9"]
"requirements": ["buienradar==1.0.6"]
}
@@ -24,7 +24,7 @@ from homeassistant.components.websocket_api import (
ActiveConnection,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EVENT, STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
@@ -45,6 +45,7 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import (
CONF_EVENT,
DATA_COMPONENT,
DOMAIN,
EVENT_DESCRIPTION,
@@ -13,6 +13,9 @@ if TYPE_CHECKING:
DOMAIN = "calendar"
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
# pylint: disable-next=home-assistant-duplicate-const
CONF_EVENT = "event"
class CalendarEntityFeature(IntFlag):
"""Supported features of the calendar entity."""
+108
View File
@@ -0,0 +1,108 @@
"""LLM tools for the calendar integration."""
from datetime import timedelta
from typing import cast
import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import intent, llm
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType
from . import SERVICE_GET_EVENTS
from .const import DOMAIN
async def async_setup_tools(hass: HomeAssistant) -> None:
"""Set up the calendar LLM tools."""
llm.async_register_tool_provider(
hass, _calendar_tools, apis={llm.LLM_API_ASSIST: None}
)
@callback
def _calendar_tools(hass: HomeAssistant, llm_context: llm.LLMContext) -> llm.LLMTools:
"""Return the calendar tools for the exposed calendars."""
if llm_context.assistant is None:
return llm.LLMTools(tools=[])
exposed = llm.async_get_exposed_entities(
hass, llm_context.assistant, include_state=False
)
if not exposed[DOMAIN]:
return llm.LLMTools(tools=[])
names = []
for info in exposed[DOMAIN].values():
names.extend(info["names"].split(", "))
return llm.LLMTools(tools=[CalendarGetEventsTool(names)])
class CalendarGetEventsTool(llm.Tool):
"""LLM Tool allowing querying a calendar."""
name = "calendar_get_events"
description = (
"Get events from a calendar. "
"When asked if something happens, search the whole week. "
"Results are RFC 5545 which means 'end' is exclusive."
)
def __init__(self, calendars: list[str]) -> None:
"""Init the get events tool."""
self.parameters = vol.Schema(
{
vol.Required("calendar"): vol.In(calendars),
vol.Required("range"): vol.In(["today", "week"]),
}
)
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> JsonObjectType:
"""Query a calendar."""
data = self.parameters(tool_input.tool_args)
result = intent.async_match_targets(
hass,
intent.MatchTargetsConstraints(
name=data["calendar"],
domains=[DOMAIN],
assistant=llm_context.assistant,
),
)
if not result.is_match:
return {"success": False, "error": "Calendar not found"}
entity_id = result.states[0].entity_id
if data["range"] == "today":
start = dt_util.now()
end = dt_util.start_of_local_day() + timedelta(days=1)
elif data["range"] == "week":
start = dt_util.now()
end = dt_util.start_of_local_day() + timedelta(days=7)
service_data = {
"entity_id": entity_id,
"start_date_time": start.isoformat(),
"end_date_time": end.isoformat(),
}
service_result = await hass.services.async_call(
DOMAIN,
SERVICE_GET_EVENTS,
service_data,
context=llm_context.context,
blocking=True,
return_response=True,
)
events = [
event if "T" in event["start"] else {**event, "all_day": True}
for event in cast(dict, service_result)[entity_id]["events"]
]
return {"success": True, "result": events}
+1 -1
View File
@@ -460,7 +460,7 @@ class EventTrigger(Trigger):
listener = TargetCalendarEventListener(
self._hass, target_selection, self._event_type, offset, run_action
)
return await listener.async_setup()
return listener.async_setup()
class EventStartedTrigger(EventTrigger):
+4 -10
View File
@@ -785,9 +785,7 @@ class CameraView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise (
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
)
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
@@ -795,15 +793,11 @@ class CameraView(HomeAssistantView):
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
# Invalid sigAuth or camera access token
raise web.HTTPForbidden
if not camera.is_on:
@@ -3,7 +3,6 @@
from pycasperglow import CasperGlow
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -28,12 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass, address.upper(), BluetoothReachabilityIntent.CONNECTION
),
},
translation_placeholders={"address": address},
)
glow = CasperGlow(ble_device)
@@ -56,7 +56,7 @@
"message": "An error occurred while communicating with the Casper Glow: {error}"
},
"device_not_found": {
"message": "Could not find Casper Glow device with address {address}: {reason}"
"message": "Could not find Casper Glow device with address {address}"
}
}
}
+1 -7
View File
@@ -49,6 +49,7 @@ async def async_setup_entry(
class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Climate device for CCM15 coordinator."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_target_temperature_step = PRECISION_WHOLE
_attr_hvac_modes = [
@@ -92,13 +93,6 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Return device data."""
return self.coordinator.get_ac_data(self._ac_index)
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement reported by the device."""
if (data := self.data) is not None and not data.is_celsius:
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def current_temperature(self) -> int | None:
"""Return current temperature."""
@@ -3,7 +3,11 @@
from cieloconnectapi.device import CieloDeviceAPI
from cieloconnectapi.model import CieloDevice
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -65,7 +69,7 @@ class CieloDeviceEntity(CieloBaseEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
manufacturer="Cielo",
configuration_url="https://home.cielowigle.com/",
suggested_area=device.name,
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["webexpythonsdk"],
"quality_scale": "legacy",
"requirements": ["webexpythonsdk==2.0.6"]
"requirements": ["webexpythonsdk==2.0.1"]
}
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.5"]
"requirements": ["aiocomelit==2.0.3"]
}
@@ -8,7 +8,7 @@ from hassil.recognize import RecognizeResult
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL, SERVICE_RELOAD
from homeassistant.const import MATCH_ALL
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -53,6 +53,7 @@ from .const import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
)
from .default_agent import async_setup_default_agent
@@ -19,6 +19,8 @@ ATTR_AGENT_ID = "agent_id"
ATTR_CONVERSATION_ID = "conversation_id"
SERVICE_PROCESS = "process"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_RELOAD = "reload"
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
@@ -2,7 +2,7 @@
"domain": "conversation",
"name": "Conversation",
"codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
"dependencies": ["http", "intent"],
"dependencies": ["http", "intent", "llm"],
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
@@ -5,7 +5,6 @@ import logging
from aiohttp import ClientConnectionError
from pydaikin.daikin_base import Appliance
from pydaikin.exceptions import DaikinException
from pydaikin.factory import DaikinFactory
from homeassistant.const import (
@@ -57,13 +56,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
except ClientConnectionError as err:
_LOGGER.debug("ClientConnectionError to %s", host)
raise ConfigEntryNotReady from err
except DaikinException as err:
# pydaikin has no subclass hierarchy for transient vs permanent errors.
# DaikinException during factory/init almost always means the device is not
# yet ready (e.g. "Empty values." when the unit hasn't finished booting),
# so treat all factory-time DaikinExceptions as transient.
_LOGGER.debug("DaikinException from %s: %s", host, err)
raise ConfigEntryNotReady from err
coordinator = DaikinCoordinator(hass, entry, device)
@@ -5,12 +5,7 @@ import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import (
DataGrandLyonClient,
TclStop,
VelovStation,
find_tcl_stop_by_id,
)
from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id
import voluptuous as vol
from homeassistant.config_entries import (
@@ -54,6 +49,12 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema(
}
)
STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_STATION_ID): vol.Coerce(int),
}
)
class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Data Grand Lyon."""
@@ -301,96 +302,27 @@ def _stop_label(stop: TclStop) -> str:
class VelovStationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Vélo'v station."""
def __init__(self) -> None:
"""Initialize the flow."""
self._stations: list[VelovStation] = []
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Pick a station from the list fetched from the API, or enter one manually."""
if not self._stations:
if error := await self._async_load_stations():
return self.async_abort(reason=error)
"""Handle the user step to add a new Vélo'v station."""
entry = self._get_entry()
errors: dict[str, str] = {}
if user_input is not None:
try:
station_id = int(user_input[CONF_STATION_ID])
except ValueError:
errors[CONF_STATION_ID] = "invalid_station_id"
else:
entry = self._get_entry()
unique_id = f"velov_{station_id}"
station_id = user_input[CONF_STATION_ID]
unique_id = f"velov_{station_id}"
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=f"Vélo'v {station_id}",
data={CONF_STATION_ID: station_id},
unique_id=unique_id,
)
options = [
SelectOptionDict(
value=str(station.number), label=_velov_station_label(station)
return self.async_create_entry(
title=f"Vélo'v {station_id}",
data={CONF_STATION_ID: station_id},
unique_id=unique_id,
)
for station in sorted(
self._stations,
key=lambda s: (s.name, s.commune or "", s.number or 0),
)
]
schema = vol.Schema(
{
vol.Required(CONF_STATION_ID): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
sort=False,
custom_value=True,
)
)
}
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
data_schema=STEP_VELOV_STATION_DATA_SCHEMA,
)
async def _async_load_stations(self) -> str | None:
"""Fetch Vélo'v stations from the API, returning an error key on failure."""
entry = self._get_entry()
session = async_get_clientsession(self.hass)
client = DataGrandLyonClient(
session=session,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
try:
self._stations = await client.get_velov_stations()
except ClientResponseError as err:
if err.status in (401, 403):
return "invalid_auth"
return "cannot_connect"
except ClientError, TimeoutError:
return "cannot_connect"
except Exception:
_LOGGER.exception(
"Unexpected error fetching Data Grand Lyon Vélo'v stations"
)
return "unknown"
return None
def _velov_station_label(station: VelovStation) -> str:
label = station.name
if station.address or station.commune:
label += (
" (" + ", ".join(filter(None, [station.address, station.commune])) + ")"
)
label += f" - {station.number}"
return label
@@ -76,25 +76,16 @@
},
"velov_station": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"entry_type": "Vélo'v station",
"error": {
"invalid_station_id": "Station ID must be a number."
},
"initiate_flow": {
"user": "Add Vélo'v station"
},
"step": {
"user": {
"data": {
"station_id": "Station"
},
"data_description": {
"station_id": "Search by station name, address or city, or enter a station ID directly."
"station_id": "Station ID"
}
}
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["datadog"],
"requirements": ["datadog==0.52.1"]
"requirements": ["datadog==0.52.0"]
}
@@ -6,5 +6,5 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["debugpy==1.8.21"]
"requirements": ["debugpy==1.8.17"]
}
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["denonavr"],
"requirements": ["denonavr==1.3.3"],
"requirements": ["denonavr==1.3.2"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
@@ -9,6 +9,6 @@
"iot_class": "local_push",
"loggers": ["HomeControl", "Mydevolo", "MprmRest", "MprmWebsocket", "Mprm"],
"quality_scale": "silver",
"requirements": ["devolo-home-control-api==0.19.1"],
"requirements": ["devolo-home-control-api==0.19.0"],
"zeroconf": ["_dvl-deviceapi._tcp.local."]
}
+1 -1
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.7",
"aiodiscover==3.3.2",
"aiodiscover==3.3.1",
"cached-ipaddress==1.1.2"
]
}
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["doorbirdpy"],
"requirements": ["DoorBirdPy==3.0.12"],
"requirements": ["DoorBirdPy==3.0.11"],
"zeroconf": [
{
"properties": {
@@ -19,19 +19,11 @@ from .coordinator import DucoConfigEntry
TO_REDACT = {
CONF_HOST,
"mac",
"Mac",
"host_name",
"HostName",
"serial_board_box",
"SerialBoardBox",
"serial_board_comm",
"SerialBoardComm",
"serial_duco_box",
"SerialDucoBox",
"serial_duco_comm",
"SerialDucoComm",
"WifiApKey",
"WifiApSsid",
}
+2 -12
View File
@@ -95,12 +95,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
value_fn=lambda node: node.sensor.co2 if node.sensor else None,
node_types=(
NodeType.BSCO2,
NodeType.UCCO2,
NodeType.VLVCO2,
NodeType.VLVCO2RH,
),
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
),
DucoSensorEntityDescription(
key="iaq_co2",
@@ -109,12 +104,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_co2 if node.sensor else None,
node_types=(
NodeType.BSCO2,
NodeType.UCCO2,
NodeType.VLVCO2,
NodeType.VLVCO2RH,
),
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
),
DucoSensorEntityDescription(
key="humidity",
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pdunehd"],
"requirements": ["pdunehd==1.3.3"]
"requirements": ["pdunehd==1.3.2"]
}
+1 -3
View File
@@ -30,9 +30,7 @@
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"description": "Please enter the API key obtained from ecobee.com."
}
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -1,180 +0,0 @@
"""Button platform for Edifier infrared integration."""
from dataclasses import dataclass
from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode
from .entity import EdifierIrEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class EdifierIrButtonEntityDescription(ButtonEntityDescription):
"""Describes Edifier IR button entity."""
command_code: EdifierCode
COMMAND_SET_BUTTONS: dict[
EdifierCommandSet,
tuple[EdifierIrButtonEntityDescription, ...],
] = {
EdifierCommandSet.R1700BT: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierR1700BTCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="line_1",
translation_key="line_1",
command_code=EdifierR1700BTCode.LINE_1,
),
EdifierIrButtonEntityDescription(
key="line_2",
translation_key="line_2",
command_code=EdifierR1700BTCode.LINE_2,
),
EdifierIrButtonEntityDescription(
key="fx_on",
translation_key="fx_on",
command_code=EdifierR1700BTCode.FX_ON,
),
EdifierIrButtonEntityDescription(
key="fx_off",
translation_key="fx_off",
command_code=EdifierR1700BTCode.FX_OFF,
),
),
EdifierCommandSet.R1280DB: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierR1280DBCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="line_1",
translation_key="line_1",
command_code=EdifierR1280DBCode.LINE_1,
),
EdifierIrButtonEntityDescription(
key="line_2",
translation_key="line_2",
command_code=EdifierR1280DBCode.LINE_2,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierR1280DBCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierR1280DBCode.COAX,
),
),
EdifierCommandSet.S360DB: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierS360DBCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierS360DBCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierS360DBCode.COAX,
),
EdifierIrButtonEntityDescription(
key="pc",
translation_key="pc",
command_code=EdifierS360DBCode.PC,
),
EdifierIrButtonEntityDescription(
key="aux",
translation_key="aux",
command_code=EdifierS360DBCode.AUX,
),
),
EdifierCommandSet.RC20G: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierRC20GCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="pc",
translation_key="pc",
command_code=EdifierRC20GCode.PC,
),
EdifierIrButtonEntityDescription(
key="aux",
translation_key="aux",
command_code=EdifierRC20GCode.AUX,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierRC20GCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierRC20GCode.COAX,
),
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Edifier IR buttons from a config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET])
model = EdifierModel(entry.data[CONF_MODEL])
async_add_entities(
EdifierIrButton(entry, model, infrared_entity_id, description)
for description in COMMAND_SET_BUTTONS.get(command_set, ())
)
class EdifierIrButton(EdifierIrEntity, InfraredEmitterConsumerEntity, ButtonEntity):
"""Edifier IR button entity."""
entity_description: EdifierIrButtonEntityDescription
def __init__(
self,
entry: ConfigEntry,
model: EdifierModel,
infrared_entity_id: str,
description: EdifierIrButtonEntityDescription,
) -> None:
"""Initialize Edifier IR button."""
super().__init__(entry, model, unique_id_suffix=description.key)
self._infrared_emitter_entity_id = infrared_entity_id
self.entity_description = description
async def async_press(self) -> None:
"""Press the button."""
await self._send_command(self.entity_description.command_code.to_command())
@@ -18,36 +18,5 @@
"title": "Set up Edifier IR speaker"
}
}
},
"entity": {
"button": {
"aux": {
"name": "AUX"
},
"bluetooth": {
"name": "Bluetooth"
},
"coax": {
"name": "Coaxial"
},
"fx_off": {
"name": "FX off"
},
"fx_on": {
"name": "FX on"
},
"line_1": {
"name": "Line 1"
},
"line_2": {
"name": "Line 2"
},
"optical": {
"name": "Optical"
},
"pc": {
"name": "PC"
}
}
}
}
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "platinum",
"requirements": ["eheimdigital==1.7.0"],
"requirements": ["eheimdigital==1.6.0"],
"zeroconf": [
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
]
+8 -2
View File
@@ -1,7 +1,11 @@
"""Base entity for the Elgato integration."""
from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -29,4 +33,6 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]):
hw_version=str(coordinator.data.info.hardware_board_type),
)
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
self._attr_device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, format_mac(mac))
}
@@ -93,7 +93,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
if "mac" in iface and iface["mac"] is not None
}
self.device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, iface["mac"])
(CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
for iface in about["info"]["ifaces"]
if "mac" in iface and iface["mac"] is not None
}
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.9"],
"requirements": ["pyenphase==2.4.8"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
@@ -19,7 +19,7 @@
"requirements": [
"aioesphomeapi==45.3.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.4"
"bleak-esphome==3.9.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
@@ -2,12 +2,6 @@
"domain": "eufylife_ble",
"name": "EufyLife",
"bluetooth": [
{
"local_name": "eufy T9120"
},
{
"local_name": "eufy T9130"
},
{
"local_name": "eufy T9140"
},
@@ -22,9 +16,6 @@
},
{
"local_name": "eufy T9149"
},
{
"local_name": "eufy T9150"
}
],
"codeowners": ["@bdr99"],
@@ -33,5 +24,5 @@
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["eufylife-ble-client==0.1.10"]
"requirements": ["eufylife-ble-client==0.1.8"]
}
@@ -57,7 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) ->
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
name=f"{ble_device_info['model']} {cometblue_device.device.address}",
manufacturer=ble_device_info["manufacturer"],
model=ble_device_info["model"],
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyfireservicerota"],
"requirements": ["pyfireservicerota==0.0.49"]
"requirements": ["pyfireservicerota==0.0.46"]
}
@@ -14,5 +14,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["bleak", "fjaraskupan"],
"requirements": ["fjaraskupan==2.3.4"]
"requirements": ["fjaraskupan==2.3.3"]
}
+12 -44
View File
@@ -1,6 +1,5 @@
"""Config flow for Fluss+ integration."""
from collections.abc import Mapping
from typing import Any
from fluss_api import (
@@ -23,21 +22,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string})
class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fluss+."""
async def _validate_api_key(self, api_key: str) -> dict[str, str]:
"""Validate the API key and return any errors."""
errors: dict[str, str] = {}
client = FlussApiClient(api_key, session=async_get_clientsession(self.hass))
try:
await client.async_get_devices()
except FlussApiClientCommunicationError:
errors["base"] = "cannot_connect"
except FlussApiClientAuthenticationError:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception occurred")
errors["base"] = "unknown"
return errors
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -47,7 +31,18 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match({CONF_API_KEY: api_key})
errors = await self._validate_api_key(api_key)
client = FlussApiClient(
user_input[CONF_API_KEY], session=async_get_clientsession(self.hass)
)
try:
await client.async_get_devices()
except FlussApiClientCommunicationError:
errors["base"] = "cannot_connect"
except FlussApiClientAuthenticationError:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception occurred")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title="My Fluss+ Devices", data=user_input
@@ -56,30 +51,3 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication when the API key is no longer valid."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm re-authentication with a new API key."""
errors: dict[str, str] = {}
if user_input is not None:
api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match({CONF_API_KEY: api_key})
errors = await self._validate_api_key(api_key)
if not errors:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_KEY: api_key},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -12,7 +12,7 @@ from fluss_api import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -60,7 +60,7 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
try:
devices = await self.api.async_get_devices()
except FlussApiClientAuthenticationError as err:
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
raise ConfigEntryError(f"Authentication failed: {err}") from err
except FlussApiClientError as err:
raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err
@@ -29,7 +29,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: todo
# Gold
entity-translations: done
+1 -11
View File
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -10,15 +9,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key found in the profile page of the Fluss+ app."
},
"description": "The Fluss+ API key is no longer valid. Get your API key from the profile page of the Fluss+ app, or generate a new one, and enter it below."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
@@ -13,11 +13,6 @@
"discovery_confirm": {
"description": "Do you want to set up {model} {id} ({ipaddr})?"
},
"pick_device": {
"data": {
"device": "[%key:common::config_flow::data::device%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["foobot_async"],
"quality_scale": "legacy",
"requirements": ["foobot_async==1.0.1"]
"requirements": ["foobot_async==1.0.0"]
}
+1 -1
View File
@@ -5,7 +5,7 @@
"data_description_password": "Password for the FRITZ!Box.",
"data_description_port": "Leave empty to use the default port.",
"data_description_ssl": "Use SSL to connect to the FRITZ!Box.",
"data_description_username": "Username for the FRITZ!Box. FRITZ!Powerline devices ignore this information and accept any value.",
"data_description_username": "Username for the FRITZ!Box.",
"data_feature_device_tracking": "Enable network device tracking"
},
"config": {
+1 -1
View File
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["fyta_cli"],
"quality_scale": "platinum",
"requirements": ["fyta_cli==0.7.3"]
"requirements": ["fyta_cli==0.7.2"]
}
@@ -65,16 +65,14 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
mfg = await async_get_product(self.hass, discovery_info.address)
self.devices[discovery_info.address] = mfg
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
self.address = discovery_info.address
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
async def async_step_confirm(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/geniushub",
"iot_class": "local_polling",
"loggers": ["geniushubclient"],
"requirements": ["geniushub-client==0.7.4"]
"requirements": ["geniushub-client==0.7.1"]
}
@@ -11,7 +11,6 @@
"step": {
"user": {
"data": {
"device": "[%key:common::config_flow::data::device%]",
"ip_address": "[%key:common::config_flow::data::ip%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
@@ -230,19 +230,11 @@ class GoogleGenerativeAISttEntity(
f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}",
)
prompt = self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT)
if metadata.language:
prompt = (
f"{prompt}\n"
f"The spoken language is {metadata.language}. "
f"Transcribe in that language."
)
try:
response = await self._genai_client.aio.models.generate_content(
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL),
contents=[
prompt,
self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
Part.from_bytes(
data=audio_data,
mime_type=f"audio/{metadata.format.value}",

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