mirror of
https://github.com/home-assistant/core.git
synced 2026-06-30 10:35:54 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaedff9502 | |||
| cb1f8ce440 | |||
| e0f5c99fdf | |||
| 2952bf3e1a | |||
| 1186f153cb | |||
| bc9c270117 | |||
| 8922131056 | |||
| e0ee456bfa | |||
| ebeb98dd83 | |||
| 49cff5f980 | |||
| c24186e571 | |||
| c9ea8baf61 | |||
| f21426dfa6 | |||
| a450999646 | |||
| 5b8ff19d8d | |||
| 25d505bcf3 | |||
| 91cb829881 | |||
| 4fdc4e6219 | |||
| ca7ae00c7e | |||
| ff460901b7 | |||
| 30512f08a8 | |||
| 9dd1a59d50 | |||
| 91aded4474 | |||
| 543eab3354 | |||
| 47b331a869 | |||
| 696dd45803 | |||
| f92239877f | |||
| 45ceb13937 | |||
| c5aeee8097 | |||
| bfc750b608 |
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: bump-dependency
|
||||
description: Bumps a Python package dependency across Home Assistant Core integrations, regenerates core requirement files, runs verification tests and prek lint, and prepares a pull request with proper release/compare links.
|
||||
---
|
||||
|
||||
# Bump Python Package Dependency in Home Assistant Core
|
||||
|
||||
Follow these systematic steps to successfully bump a python package requirement in the repository, regenerate necessary derivative files, verify the integration, and raise a pull request.
|
||||
|
||||
## Gotchas & Non-Obvious Constraints
|
||||
|
||||
- **PR Template Integrity**: Follow Home Assistant's Pull Request template (`.github/PULL_REQUEST_TEMPLATE.md`) exactly as written, including any instructions inside the template itself. Preserve all sections, comments, and unchecked checkboxes unless the template explicitly says otherwise; the only allowed removal is the **Breaking change** section when the template instructs you to remove it if not applicable.
|
||||
- **GitHub Tag Volatility**: Release tags on GitHub are highly inconsistent (e.g., `v1.2.3` vs `1.2.3` vs `release-1.2.3`). Always use the automated resolver `resolve_dependency.py` to check HEAD status for correct tags before hardcoding comparison URLs.
|
||||
|
||||
## Step-by-Step Workflow Checklist
|
||||
|
||||
### Phase A: Research and Plan
|
||||
- [ ] **1. Identify Targets**: Note the requested target package and target version to bump.
|
||||
- [ ] **2. Discover Codebase References**: Search the codebase to find all `manifest.json` and requirements files referencing the package.
|
||||
- [ ] **3. Resolve Version/Tag Details**: Run the integrated validation helper script to resolve version details, GitHub repo, release tag format, and formatted PR links:
|
||||
```bash
|
||||
uv run python3 ./.claude/skills/bump-dependency/scripts/resolve_dependency.py <package> <old_version> [--new-version <new_version>]
|
||||
```
|
||||
- [ ] **4. Plan-Validate-Execute (Draft Plan)**: Before modifying any files, write a brief, structured plan outlining the integrations to change, old version, new version, and the resolved comparison link. Show this draft plan to the user.
|
||||
|
||||
### Phase B: Execute and Validate (Local Changes)
|
||||
- [ ] **5. Check Uncommitted Changes**: Check for any uncommitted changes in the repository. If they exist, ask the user whether to stash, commit, or discard them before proceeding.
|
||||
- [ ] **6. Git Branch Setup**: Create a clean branch starting from the latest `upstream/dev`:
|
||||
```bash
|
||||
git fetch upstream dev
|
||||
git checkout -b bump-<package>-to-<version> upstream/dev
|
||||
```
|
||||
- [ ] **7. Apply Bump to manifests**: Update the version constraint string in all identified `manifest.json` files (e.g., change `"package==1.0.0"` to `"package==1.1.0"`).
|
||||
- [ ] **8. Regenerate Core Requirements**: Run the requirements generator to update all derivative requirements and constraint files:
|
||||
```bash
|
||||
uv run python3 -m script.gen_requirements_all
|
||||
```
|
||||
- [ ] **9. Validate Requirements**: Check `git diff` to ensure that only the targeted `manifest.json` files and `requirements_all.txt` (and potentially standard constraints) were modified. No unrelated files must be affected.
|
||||
- [ ] **10. Local Venv Verification**: Install the exact targeted package version directly inside the virtual environment:
|
||||
```bash
|
||||
uv pip install "<package>==<version>"
|
||||
```
|
||||
|
||||
### Phase C: Validation Loop (Tests & Lint)
|
||||
- [ ] **11. Run Integration Tests**: Execute the pytest suite for all integrations that consume the bumped package:
|
||||
```bash
|
||||
uv run pytest tests/components/<integration_name>
|
||||
```
|
||||
- *Validation Loop*: If tests fail, analyze the error, apply appropriate fixes, and re-run pytest until all tests pass cleanly.
|
||||
- [ ] **12. Run prek Lint Checks**: Run the local prek hooks on modified files:
|
||||
```bash
|
||||
uv run prek run
|
||||
```
|
||||
- *Validation Loop*: If prek checks report any formatting or linting violations, fix them and repeat `uv run prek run` until it passes completely without errors.
|
||||
|
||||
### Phase D: User Confirmation & PR Creation
|
||||
- [ ] **13. Commit Changes**: Commit the clean changes:
|
||||
```bash
|
||||
git add <modified_files>
|
||||
git commit -m "Bump <package> to <version>"
|
||||
```
|
||||
- [ ] **14. Push Branch**: Push the local branch to your origin remote:
|
||||
```bash
|
||||
git push origin bump-<package>-to-<version>
|
||||
```
|
||||
- [ ] **15. PR Description Preparation**: Generate the pull request body from `.github/PULL_REQUEST_TEMPLATE.md`:
|
||||
- **Proposed change**: Describe the package, old version, new version, target/source branches, and insert the resolved PyPI, changelog, and comparison diff links.
|
||||
- **Type of change**: Check only 1 box in this section, and mark the `Dependency upgrade` checkbox as checked: `[x] Dependency upgrade`.
|
||||
- **Breaking change**: You may remove the "Breaking change" section entirely from the template.
|
||||
- **Validation checklists**: Mark `The code change is tested` checkbox as checked: `[x] The code change is tested`.
|
||||
- **Keep remaining template intact**: Do NOT remove any other commented-out blocks, headers, or unchecked checkboxes in the template.
|
||||
- [ ] **16. Mandatory Review Presentation**: Format the PR proposal using the **PR Presentation Template** below and display it to the user. **Stop and wait for the user to review and explicitly confirm/approve the PR template and draft details before creating the PR.**
|
||||
- [ ] **17. Raise Pull Request**: Once the user approves, create the Pull Request using the GitHub CLI:
|
||||
```bash
|
||||
gh pr create --repo home-assistant/core --base dev --head <username>:bump-<package>-to-<version> --title "Bump <package> to <version>" --body-file <pr_body_file>
|
||||
```
|
||||
|
||||
## PR Presentation Template
|
||||
|
||||
```markdown
|
||||
### 🚀 Dependency Bump Pull Request Draft Review
|
||||
|
||||
- **Package**: `<package_name>` (`<old_version>` → `<new_version>`)
|
||||
- **PR Title**: `Bump <package_name> to <new_version>`
|
||||
- **Target Branch**: `dev`
|
||||
- **Head Branch**: `<fork_username>:bump-<package_name>-to-<new_version>`
|
||||
|
||||
#### 🔗 PyPI & GitHub Links
|
||||
- **PyPI Release**: https://pypi.org/project/<package_name>/<new_version>/
|
||||
- **Changelog Link**: `<changelog_url>`
|
||||
- **Comparison Diff**: `<compare_url>`
|
||||
|
||||
#### 📁 Modified Files
|
||||
- `<list_of_modified_files>`
|
||||
|
||||
#### 📝 Proposed PR Body
|
||||
<render the complete filled PR template body here, showing all checks and modifications for user approval>
|
||||
```
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
# ruff: noqa: T201, D103, BLE001
|
||||
"""Helper script to resolve package details, GitHub repo, release tags, and diff links from PyPI."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
_VERSION_MATCH = r"^[a-zA-Z0-9_\-\.\+\!\*]+$"
|
||||
_REPO_MATCH = r"https?://(?:www\.)?github\.com/([^/]+)/([^/]+)"
|
||||
# Project owner or repo name
|
||||
_NAME_MATCH = r"^[a-zA-Z0-9_\-\.]+$"
|
||||
|
||||
|
||||
def get_pypi_data(package_name):
|
||||
# Sanitize and URL-quote the package name to prevent URL path injection
|
||||
safe_package_name = urllib.parse.quote(package_name)
|
||||
url = f"https://pypi.org/pypi/{safe_package_name}/json"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "HomeAssistant-Skill-Resolver/1.0",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"Error fetching PyPI data (HTTP {e.code}): {e.reason}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(f"Network error fetching PyPI data: {e.reason}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Unexpected error fetching PyPI data: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def find_github_repo(info):
|
||||
urls = []
|
||||
if info.get("home_page"):
|
||||
urls.append(info["home_page"])
|
||||
if info.get("project_urls"):
|
||||
urls.extend(info["project_urls"].values())
|
||||
|
||||
for u in urls:
|
||||
if not u:
|
||||
continue
|
||||
cleaned_url = u.replace("git+", "")
|
||||
m = re.search(_REPO_MATCH, cleaned_url, re.IGNORECASE)
|
||||
if m:
|
||||
owner, repo = m.groups()
|
||||
# Strip query parameters, hashes, trailing slashes, and .git extension
|
||||
repo = repo.split("?")[0].split("#")[0].split("/")[0]
|
||||
repo = repo.removesuffix(".git")
|
||||
# Validate that the owner and repo name conform to valid formats,
|
||||
# preventing prompt injection payloads embedded in repository URLs.
|
||||
if re.match(_NAME_MATCH, owner) and re.match(_NAME_MATCH, repo):
|
||||
return f"https://github.com/{owner}/{repo}"
|
||||
return None
|
||||
|
||||
|
||||
def check_github_tag(repo_url, version):
|
||||
# Try with 'v' prefix, without prefix, and with 'release-' prefix
|
||||
tag_options = [f"v{version}", version, f"release-{version}"]
|
||||
for tag in tag_options:
|
||||
# Check both tree and releases paths on GitHub
|
||||
for path_template in [f"tree/{tag}", f"releases/tag/{tag}"]:
|
||||
url = f"{repo_url}/{path_template}"
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
method="HEAD",
|
||||
headers={"User-Agent": "HomeAssistant-Skill-Resolver/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
if resp.status == 200:
|
||||
return tag
|
||||
except urllib.error.HTTPError as e:
|
||||
# If it's a 404, we continue checking other tag/path options
|
||||
if e.code == 404:
|
||||
continue
|
||||
# For non-404 HTTP errors (like 403 Forbidden/429 rate limit), exit with error to avoid false reports
|
||||
print(
|
||||
f"\n[ERROR] HTTP error contacting GitHub ({e.code} {e.reason}) for tag '{tag}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
# Connection or timeout error
|
||||
print(
|
||||
f"\n[ERROR] Network error contacting GitHub: {e.reason}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
# Unexpected exceptions
|
||||
print(
|
||||
f"\n[ERROR] Unexpected error verifying tag '{tag}': {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Resolve PyPI package info and GitHub release diffs."
|
||||
)
|
||||
parser.add_argument("package", help="Name of the PyPI package")
|
||||
parser.add_argument(
|
||||
"old_version", help="Current version installed in Home Assistant"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--new-version", help="Target version to bump to (defaults to latest on PyPI)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not re.match(_NAME_MATCH, args.package):
|
||||
print(f"[ERROR] Invalid package name format: '{args.package}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not re.match(_VERSION_MATCH, args.old_version):
|
||||
print(
|
||||
f"[ERROR] Invalid old version format: '{args.old_version}'", file=sys.stderr
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if args.new_version and not re.match(_VERSION_MATCH, args.new_version):
|
||||
print(
|
||||
f"[ERROR] Invalid target version format: '{args.new_version}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
data = get_pypi_data(args.package)
|
||||
info = data.get("info", {})
|
||||
|
||||
# Filter out pre-releases from the releases list to identify the latest stable version
|
||||
stable_versions = []
|
||||
for v in data.get("releases", {}):
|
||||
try:
|
||||
ver = Version(v)
|
||||
if not ver.is_prerelease:
|
||||
stable_versions.append(ver)
|
||||
except Exception:
|
||||
continue
|
||||
latest = str(max(stable_versions)) if stable_versions else info.get("version")
|
||||
|
||||
if latest and not re.match(_VERSION_MATCH, latest):
|
||||
print("[ERROR] Invalid latest version resolved from PyPI.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
target_version = args.new_version or latest
|
||||
if not target_version:
|
||||
print("[ERROR] Could not resolve a target version from PyPI.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
github_repo = find_github_repo(info)
|
||||
|
||||
print("--- RESOLVED DEPENDENCY DETAILS ---")
|
||||
print(f"Package: {args.package}")
|
||||
print(f"Current Version: {args.old_version}")
|
||||
print(f"Latest (PyPI): {latest}")
|
||||
print(f"Target Version: {target_version}")
|
||||
|
||||
if github_repo:
|
||||
print(f"GitHub Repository: {github_repo}")
|
||||
|
||||
# Verify tag formats on GitHub
|
||||
old_tag = check_github_tag(github_repo, args.old_version)
|
||||
new_tag = check_github_tag(github_repo, target_version)
|
||||
|
||||
if not old_tag or not new_tag:
|
||||
missing_tags = []
|
||||
if not old_tag:
|
||||
missing_tags.append(args.old_version)
|
||||
if not new_tag:
|
||||
missing_tags.append(target_version)
|
||||
print(
|
||||
f"\n[ERROR] Could not resolve GitHub release tag for version(s): {', '.join(missing_tags)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
changelog_url = f"{github_repo}/releases/tag/{new_tag}"
|
||||
compare_url = f"{github_repo}/compare/{old_tag}...{new_tag}"
|
||||
|
||||
print("\n--- PR DOCUMENTATION LINKS ---")
|
||||
print(f"Changelog Link: {changelog_url}")
|
||||
print(f"Comparison Diff Link: {compare_url}")
|
||||
else:
|
||||
print("\n[WARNING] GitHub repository could not be resolved from PyPI metadata.")
|
||||
print("Please resolve the release notes and diff links manually.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -54,3 +54,4 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- 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 section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading.
|
||||
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.17
|
||||
rev: v0.15.18
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -43,3 +43,4 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- 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 section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading.
|
||||
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
"""Init file for Home Assistant."""
|
||||
|
||||
from probatio.compat import install_as_voluptuous
|
||||
|
||||
# Probatio replaces voluptuous as the validation engine. Custom integrations and a
|
||||
# few dependencies still import voluptuous directly, so alias it to probatio in
|
||||
# sys.modules before anything imports it. This must run before the first
|
||||
# `import voluptuous`, hence the package __init__.
|
||||
install_as_voluptuous()
|
||||
|
||||
@@ -6,8 +6,8 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any, cast, override
|
||||
|
||||
import anthropic
|
||||
from probatio import to_openapi as convert
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components.zone import ENTITY_ID_HOME
|
||||
from homeassistant.config_entries import (
|
||||
@@ -298,7 +298,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
):
|
||||
self.options.pop(CONF_LLM_HASS_API)
|
||||
if not errors:
|
||||
return await self.async_step_advanced()
|
||||
return await self.async_step_additional()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
@@ -308,10 +308,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
errors=errors or None,
|
||||
)
|
||||
|
||||
async def async_step_advanced(
|
||||
async def async_step_additional(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Manage advanced options."""
|
||||
"""Manage additional options."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
|
||||
@@ -360,7 +360,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
return await self.async_step_model()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="advanced",
|
||||
step_id="additional",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(step_schema), self.options
|
||||
),
|
||||
|
||||
@@ -103,8 +103,8 @@ from anthropic.types.web_fetch_tool_result_block import (
|
||||
from anthropic.types.web_fetch_tool_result_block_param import (
|
||||
Content as WebFetchToolResultBlockParamContentParam,
|
||||
)
|
||||
from probatio import to_openapi as convert
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
|
||||
@@ -48,16 +48,16 @@
|
||||
"user": "Add AI task"
|
||||
},
|
||||
"step": {
|
||||
"advanced": {
|
||||
"additional": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]"
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::additional::data::prompt_caching%]"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]"
|
||||
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::additional::data_description::chat_model%]",
|
||||
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::additional::data_description::prompt_caching%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::additional::title%]"
|
||||
},
|
||||
"init": {
|
||||
"data": {
|
||||
@@ -115,7 +115,7 @@
|
||||
"user": "Add conversation agent"
|
||||
},
|
||||
"step": {
|
||||
"advanced": {
|
||||
"additional": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"prompt_caching": "Caching strategy"
|
||||
@@ -124,7 +124,7 @@
|
||||
"chat_model": "The model to serve the responses.",
|
||||
"prompt_caching": "Optimize your API cost and response times based on your usage."
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
"title": "Additional settings"
|
||||
},
|
||||
"init": {
|
||||
"data": {
|
||||
|
||||
@@ -74,8 +74,8 @@ from ipaddress import ip_address
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from aiohttp import web
|
||||
from probatio import serialize
|
||||
import voluptuous as vol
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.auth import AuthManagerFlowManager, InvalidAuthError
|
||||
@@ -263,7 +263,7 @@ def _prepare_result_json(result: AuthFlowResult) -> dict[str, Any]:
|
||||
if (schema := result["data_schema"]) is None:
|
||||
data["data_schema"] = []
|
||||
else:
|
||||
data["data_schema"] = voluptuous_serialize.convert(schema)
|
||||
data["data_schema"] = serialize(schema)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from probatio import serialize
|
||||
import voluptuous as vol
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components import websocket_api
|
||||
@@ -153,6 +153,6 @@ def _prepare_result_json(result: data_entry_flow.FlowResult) -> dict[str, Any]:
|
||||
if (schema := result["data_schema"]) is None:
|
||||
data["data_schema"] = []
|
||||
else:
|
||||
data["data_schema"] = voluptuous_serialize.convert(schema)
|
||||
data["data_schema"] = serialize(schema)
|
||||
|
||||
return data
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_HOST, DOMAIN, SECTION_ADVANCED_SETTINGS
|
||||
from .const import DEFAULT_HOST, DOMAIN, SECTION_ADDITIONAL_SETTINGS
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -25,7 +25,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): TextSelector(
|
||||
@@ -79,7 +79,7 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
username = user_input[CONF_USERNAME].lower()
|
||||
host = user_input[SECTION_ADVANCED_SETTINGS][CONF_HOST].lower()
|
||||
host = user_input[SECTION_ADDITIONAL_SETTINGS][CONF_HOST].lower()
|
||||
|
||||
try:
|
||||
cv.url(host)
|
||||
|
||||
@@ -5,5 +5,5 @@ from datetime import timedelta
|
||||
DOMAIN = "autoskope"
|
||||
|
||||
DEFAULT_HOST = "https://portal.autoskope.de"
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
SECTION_ADDITIONAL_SETTINGS = "additional_settings"
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"description": "Enter your Autoskope credentials.",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"additional_settings": {
|
||||
"data": {
|
||||
"host": "API endpoint"
|
||||
},
|
||||
|
||||
@@ -42,8 +42,8 @@ from openai.types.responses.response_input_param import (
|
||||
ImageGenerationCall as ImageGenerationCallParam,
|
||||
)
|
||||
from openai.types.responses.response_output_item import ImageGenerationCall
|
||||
from probatio import to_openapi as convert
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
@@ -8,7 +8,6 @@ from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityTriggerBase,
|
||||
NotTriggeredReasonReporter,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
@@ -31,11 +30,7 @@ class CounterBaseIntegerTrigger(EntityTriggerBase):
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state is valid."""
|
||||
return _is_integer_state(state)
|
||||
|
||||
@@ -68,11 +63,7 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger):
|
||||
"""Trigger for when a counter reaches its maximum value."""
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected state(s)."""
|
||||
if (max_value := state.attributes.get(CONF_MAXIMUM)) is None:
|
||||
return False
|
||||
@@ -83,11 +74,7 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger):
|
||||
"""Trigger for when a counter reaches its minimum value."""
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected state(s)."""
|
||||
if (min_value := state.attributes.get(CONF_MINIMUM)) is None:
|
||||
return False
|
||||
@@ -98,11 +85,7 @@ class CounterResetTrigger(CounterValueBaseTrigger):
|
||||
"""Trigger for reset of counter entities."""
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected state(s)."""
|
||||
if (init_state := state.attributes.get(CONF_INITIAL)) is None:
|
||||
return False
|
||||
|
||||
@@ -5,11 +5,7 @@ from typing import override
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTriggerBase,
|
||||
NotTriggeredReasonReporter,
|
||||
Trigger,
|
||||
)
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
@@ -28,11 +24,7 @@ class CoverTriggerBase(EntityTriggerBase):
|
||||
return state.state
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target cover state."""
|
||||
domain_spec = self._domain_specs[state.domain]
|
||||
return self._get_value(state) == domain_spec.target_value
|
||||
|
||||
@@ -9,8 +9,8 @@ import logging
|
||||
from types import ModuleType
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
|
||||
from probatio import serialize
|
||||
import voluptuous as vol
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.websocket_api import ActiveConnection
|
||||
@@ -318,7 +318,7 @@ async def _async_get_device_automation_capabilities(
|
||||
if (extra_fields := capabilities.get("extra_fields")) is None:
|
||||
capabilities["extra_fields"] = []
|
||||
else:
|
||||
capabilities["extra_fields"] = voluptuous_serialize.convert(
|
||||
capabilities["extra_fields"] = serialize(
|
||||
extra_fields, custom_serializer=cv.custom_serializer
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.data_entry_flow import SectionConfig, section
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_ADVANCED_OPTIONS,
|
||||
CONF_ADDITIONAL_OPTIONS,
|
||||
CONF_HOSTNAME,
|
||||
CONF_IPV4,
|
||||
CONF_IPV6,
|
||||
@@ -39,7 +39,7 @@ from .const import (
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
|
||||
vol.Required(CONF_ADVANCED_OPTIONS): section(
|
||||
vol.Required(CONF_ADDITIONAL_OPTIONS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_RESOLVER): cv.string,
|
||||
@@ -117,13 +117,13 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input:
|
||||
hostname = user_input[CONF_HOSTNAME]
|
||||
name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname
|
||||
advanced_options = user_input[CONF_ADVANCED_OPTIONS]
|
||||
resolver = advanced_options.get(CONF_RESOLVER, DEFAULT_RESOLVER)
|
||||
resolver_ipv6 = advanced_options.get(
|
||||
additional_options = user_input[CONF_ADDITIONAL_OPTIONS]
|
||||
resolver = additional_options.get(CONF_RESOLVER, DEFAULT_RESOLVER)
|
||||
resolver_ipv6 = additional_options.get(
|
||||
CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6
|
||||
)
|
||||
port = advanced_options.get(CONF_PORT, DEFAULT_PORT)
|
||||
port_ipv6 = advanced_options.get(CONF_PORT_IPV6, DEFAULT_PORT)
|
||||
port = additional_options.get(CONF_PORT, DEFAULT_PORT)
|
||||
port_ipv6 = additional_options.get(CONF_PORT_IPV6, DEFAULT_PORT)
|
||||
|
||||
validate = await async_validate_hostname(
|
||||
hostname, resolver, resolver_ipv6, port, port_ipv6
|
||||
|
||||
@@ -12,7 +12,7 @@ CONF_PORT_IPV6 = "port_ipv6"
|
||||
CONF_IPV4 = "ipv4"
|
||||
CONF_IPV6 = "ipv6"
|
||||
CONF_IPV6_V4 = "ipv6_v4"
|
||||
CONF_ADVANCED_OPTIONS = "advanced_options"
|
||||
CONF_ADDITIONAL_OPTIONS = "additional_options"
|
||||
|
||||
DEFAULT_HOSTNAME = "myip.opendns.com"
|
||||
DEFAULT_IPV6 = False
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"hostname": "The hostname for which to perform the DNS query."
|
||||
},
|
||||
"sections": {
|
||||
"advanced_options": {
|
||||
"additional_options": {
|
||||
"data": {
|
||||
"port": "IPv4 port",
|
||||
"port_ipv6": "IPv6 port",
|
||||
@@ -63,16 +63,16 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port%]",
|
||||
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port_ipv6%]",
|
||||
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver%]",
|
||||
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver_ipv6%]"
|
||||
"port": "[%key:component::dnsip::config::step::user::sections::additional_options::data::port%]",
|
||||
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::additional_options::data::port_ipv6%]",
|
||||
"resolver": "[%key:component::dnsip::config::step::user::sections::additional_options::data::resolver%]",
|
||||
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::additional_options::data::resolver_ipv6%]"
|
||||
},
|
||||
"data_description": {
|
||||
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port%]",
|
||||
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port_ipv6%]",
|
||||
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver%]",
|
||||
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver_ipv6%]"
|
||||
"port": "[%key:component::dnsip::config::step::user::sections::additional_options::data_description::port%]",
|
||||
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::additional_options::data_description::port_ipv6%]",
|
||||
"resolver": "[%key:component::dnsip::config::step::user::sections::additional_options::data_description::resolver%]",
|
||||
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::additional_options::data_description::resolver_ipv6%]"
|
||||
},
|
||||
"description": "Optionally change resolvers and ports."
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ from homeassistant.components.event import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
NotTriggeredReasonReporter,
|
||||
StatelessEntityTriggerBase,
|
||||
Trigger,
|
||||
)
|
||||
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
|
||||
|
||||
|
||||
class DoorbellRangTrigger(StatelessEntityTriggerBase):
|
||||
@@ -23,11 +19,7 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase):
|
||||
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the event type is ring."""
|
||||
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
|
||||
|
||||
|
||||
@@ -2,9 +2,23 @@
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from duco_connectivity.models import NodeType
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "duco"
|
||||
PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
BOX_NODE_ID = 1
|
||||
VENTILATION_CAPABLE_NODE_TYPES: tuple[NodeType, ...] = (
|
||||
NodeType.BOX,
|
||||
NodeType.VLV,
|
||||
NodeType.VLVRH,
|
||||
NodeType.VLVVOC,
|
||||
NodeType.VLVCO2,
|
||||
NodeType.VLVCO2RH,
|
||||
NodeType.EAV,
|
||||
NodeType.EAVRH,
|
||||
NodeType.EAVVOC,
|
||||
NodeType.EAVCO2,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ from duco_connectivity import (
|
||||
KnownActionName,
|
||||
Node,
|
||||
NodeListActionItemList,
|
||||
NodeType,
|
||||
VentilationState,
|
||||
)
|
||||
|
||||
@@ -19,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, VENTILATION_CAPABLE_NODE_TYPES
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
from .entity import DucoEntity
|
||||
|
||||
@@ -27,19 +26,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
SUPPORTED_SELECT_NODE_TYPES = {
|
||||
NodeType.BOX,
|
||||
NodeType.VLV,
|
||||
NodeType.VLVRH,
|
||||
NodeType.VLVVOC,
|
||||
NodeType.VLVCO2,
|
||||
NodeType.VLVCO2RH,
|
||||
NodeType.EAV,
|
||||
NodeType.EAVRH,
|
||||
NodeType.EAVVOC,
|
||||
NodeType.EAVCO2,
|
||||
}
|
||||
|
||||
|
||||
def _get_ventilation_options(action: ActionItem) -> tuple[str, ...] | None:
|
||||
"""Return ventilation options advertised by a node action."""
|
||||
@@ -86,7 +72,7 @@ async def async_setup_entry(
|
||||
|
||||
# Duco advertises SetVentilationState broadly, so keep the select
|
||||
# limited to the box and known valve node families.
|
||||
if node.general.node_type not in SUPPORTED_SELECT_NODE_TYPES:
|
||||
if node.general.node_type not in VENTILATION_CAPABLE_NODE_TYPES:
|
||||
continue
|
||||
|
||||
options = options_by_node.get(node.node_id)
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import BOX_NODE_ID, DOMAIN
|
||||
from .const import BOX_NODE_ID, DOMAIN, VENTILATION_CAPABLE_NODE_TYPES
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
from .entity import DucoEntity
|
||||
|
||||
@@ -65,7 +65,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
|
||||
else None
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
node_types=VENTILATION_CAPABLE_NODE_TYPES,
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="target_flow_level",
|
||||
@@ -76,7 +76,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
value_fn=lambda node: (
|
||||
node.ventilation.flow_lvl_tgt if node.ventilation else None
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
node_types=VENTILATION_CAPABLE_NODE_TYPES,
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="time_state_end",
|
||||
@@ -89,7 +89,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
if node.ventilation and node.ventilation.time_state_end != 0
|
||||
else None
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
node_types=VENTILATION_CAPABLE_NODE_TYPES,
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="co2",
|
||||
|
||||
@@ -10,7 +10,6 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
NotTriggeredReasonReporter,
|
||||
StatelessEntityTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
@@ -43,11 +42,7 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
|
||||
self._event_types = set(self._options[CONF_EVENT_TYPE])
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the event type matches one of the configured types."""
|
||||
return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ class FreeboxRouter:
|
||||
self.raids: dict[int, dict[str, Any]] = {}
|
||||
self.sensors_temperature: dict[str, int] = {}
|
||||
self.sensors_temperature_names: dict[str, str] = {}
|
||||
self.sensors_fan: dict[str, int] = {}
|
||||
self.sensors_fan_names: dict[str, str] = {}
|
||||
self.sensors_connection: dict[str, float] = {}
|
||||
self.call_list: list[dict[str, Any]] = []
|
||||
self.home_granted = True
|
||||
@@ -190,6 +192,12 @@ class FreeboxRouter:
|
||||
self.sensors_temperature[sensor_id] = sensor.get("value")
|
||||
self.sensors_temperature_names[sensor_id] = sensor["name"]
|
||||
|
||||
# Fan speed sensors (rpm). Name and id may vary under Freebox devices.
|
||||
for fan in syst_datas.get("fans", []):
|
||||
fan_id = fan["id"]
|
||||
self.sensors_fan[fan_id] = fan.get("value")
|
||||
self.sensors_fan_names[fan_id] = fan["name"]
|
||||
|
||||
# Connection sensors
|
||||
connection_datas: dict[str, Any] = await self._api.connection.get_status()
|
||||
for sensor_key in CONNECTION_SENSORS_KEYS:
|
||||
@@ -321,7 +329,11 @@ class FreeboxRouter:
|
||||
@property
|
||||
def sensors(self) -> dict[str, Any]:
|
||||
"""Return sensors."""
|
||||
return {**self.sensors_temperature, **self.sensors_connection}
|
||||
return {
|
||||
**self.sensors_temperature,
|
||||
**self.sensors_fan,
|
||||
**self.sensors_connection,
|
||||
}
|
||||
|
||||
@property
|
||||
def call(self) -> Call:
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
REVOLUTIONS_PER_MINUTE,
|
||||
EntityCategory,
|
||||
UnitOfDataRate,
|
||||
UnitOfTemperature,
|
||||
@@ -93,6 +94,27 @@ async def async_setup_entry(
|
||||
for sensor_id, sensor_name in router.sensors_temperature_names.items()
|
||||
]
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s - %s - %s fan sensors",
|
||||
router.name,
|
||||
router.mac,
|
||||
len(router.sensors_fan_names),
|
||||
)
|
||||
entities.extend(
|
||||
FreeboxSensor(
|
||||
router,
|
||||
SensorEntityDescription(
|
||||
key=fan_id,
|
||||
name=fan_name,
|
||||
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
icon="mdi:fan",
|
||||
),
|
||||
)
|
||||
for fan_id, fan_name in router.sensors_fan_names.items()
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
[FreeboxSensor(router, description) for description in CONNECTION_SENSORS]
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.5"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.3.0"]
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ from google.genai.types import (
|
||||
Tool,
|
||||
ToolListUnion,
|
||||
)
|
||||
from probatio import to_openapi as convert
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
|
||||
@@ -27,25 +27,6 @@
|
||||
"state": {
|
||||
"2d_fix": "2D Fix",
|
||||
"3d_fix": "3D Fix"
|
||||
},
|
||||
"state_attributes": {
|
||||
"climb": {
|
||||
"name": "[%key:component::gpsd::entity::sensor::climb::name%]"
|
||||
},
|
||||
"elevation": {
|
||||
"name": "[%key:common::config_flow::data::elevation%]"
|
||||
},
|
||||
"gps_time": {
|
||||
"name": "[%key:component::time_date::selector::display_options::options::time%]"
|
||||
},
|
||||
"latitude": { "name": "[%key:common::config_flow::data::latitude%]" },
|
||||
"longitude": {
|
||||
"name": "[%key:common::config_flow::data::longitude%]"
|
||||
},
|
||||
"mode": { "name": "[%key:common::config_flow::data::mode%]" },
|
||||
"speed": {
|
||||
"name": "[%key:component::sensor::entity_component::speed::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
|
||||
@@ -20,7 +20,7 @@ from .const import (
|
||||
CONF_MIN_STATE_DURATION,
|
||||
CONF_START,
|
||||
PLATFORMS,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS,
|
||||
)
|
||||
from .coordinator import HistoryStatsUpdateCoordinator
|
||||
from .data import HistoryStats
|
||||
@@ -44,8 +44,8 @@ async def async_setup_entry(
|
||||
min_state_duration: timedelta
|
||||
if duration_dict := entry.options.get(CONF_DURATION):
|
||||
duration = timedelta(**duration_dict)
|
||||
advanced_settings = entry.options.get(SECTION_ADVANCED_SETTINGS, {})
|
||||
if min_state_duration_dict := advanced_settings.get(CONF_MIN_STATE_DURATION):
|
||||
additional_settings = entry.options.get(SECTION_ADDITIONAL_SETTINGS, {})
|
||||
if min_state_duration_dict := additional_settings.get(CONF_MIN_STATE_DURATION):
|
||||
min_state_duration = timedelta(**min_state_duration_dict)
|
||||
else:
|
||||
min_state_duration = timedelta(0)
|
||||
@@ -121,6 +121,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, minor_version=3
|
||||
)
|
||||
if config_entry.minor_version < 4:
|
||||
# The "advanced_settings" section was renamed to "additional_settings"
|
||||
if (additional := options.pop("advanced_settings", None)) is not None:
|
||||
options[SECTION_ADDITIONAL_SETTINGS] = additional
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, minor_version=4
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s.%s successful",
|
||||
|
||||
@@ -44,7 +44,7 @@ from .const import (
|
||||
CONF_TYPE_TIME,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS,
|
||||
)
|
||||
from .coordinator import HistoryStatsUpdateCoordinator
|
||||
from .data import HistoryStats
|
||||
@@ -149,7 +149,7 @@ def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
),
|
||||
),
|
||||
vol.Optional(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Optional(SECTION_ADDITIONAL_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_MIN_STATE_DURATION): DurationSelector(
|
||||
@@ -189,7 +189,7 @@ OPTIONS_FLOW = {
|
||||
class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Handle a config flow for History stats."""
|
||||
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 4
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
@@ -290,8 +290,8 @@ async def ws_start_preview(
|
||||
start = validated_data.get(CONF_START)
|
||||
end = validated_data.get(CONF_END)
|
||||
duration = validated_data.get(CONF_DURATION)
|
||||
advanced_settings = validated_data.get(SECTION_ADVANCED_SETTINGS, {})
|
||||
min_state_duration = advanced_settings.get(CONF_MIN_STATE_DURATION)
|
||||
additional_settings = validated_data.get(SECTION_ADDITIONAL_SETTINGS, {})
|
||||
min_state_duration = additional_settings.get(CONF_MIN_STATE_DURATION)
|
||||
state_class = validated_data.get(CONF_STATE_CLASS)
|
||||
|
||||
history_stats = HistoryStats(
|
||||
|
||||
@@ -18,4 +18,4 @@ CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT]
|
||||
|
||||
DEFAULT_NAME = "unnamed statistics"
|
||||
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
SECTION_ADDITIONAL_SETTINGS = "additional_settings"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"description": "Read the documentation for further details on how to configure the history stats sensor using these options.",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"additional_settings": {
|
||||
"data": { "min_state_duration": "Minimum state duration" },
|
||||
"data_description": {
|
||||
"min_state_duration": "The minimum state duration to account for the statistics. Default is 0 seconds."
|
||||
@@ -93,14 +93,14 @@
|
||||
},
|
||||
"description": "[%key:component::history_stats::config::step::options::description%]",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"additional_settings": {
|
||||
"data": {
|
||||
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data::min_state_duration%]"
|
||||
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::additional_settings::data::min_state_duration%]"
|
||||
},
|
||||
"data_description": {
|
||||
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data_description::min_state_duration%]"
|
||||
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::additional_settings::data_description::min_state_duration%]"
|
||||
},
|
||||
"name": "[%key:component::history_stats::config::step::options::sections::advanced_settings::name%]"
|
||||
"name": "[%key:component::history_stats::config::step::options::sections::additional_settings::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from probatio import serialize
|
||||
from pyinsteon import async_close, async_connect, devices
|
||||
from pyinsteon.address import Address
|
||||
from pyinsteon.aldb.aldb_record import ALDBRecord
|
||||
from pyinsteon.constants import LinkStatus
|
||||
from pyinsteon.managers.link_manager import get_broken_links
|
||||
import voluptuous as vol
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -212,12 +212,10 @@ async def websocket_get_modem_schema(
|
||||
config_data = config_entry.data
|
||||
if device := config_data.get(CONF_DEVICE):
|
||||
ports = await async_get_usb_ports(hass=hass)
|
||||
plm_schema = voluptuous_serialize.convert(
|
||||
build_plm_schema(ports=ports, device=device)
|
||||
)
|
||||
plm_schema = serialize(build_plm_schema(ports=ports, device=device))
|
||||
connection.send_result(msg[ID], plm_schema)
|
||||
else:
|
||||
hub_schema = voluptuous_serialize.convert(build_hub_schema(**config_data))
|
||||
hub_schema = serialize(build_hub_schema(**config_data))
|
||||
connection.send_result(msg[ID], hub_schema)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from probatio import serialize
|
||||
from pyinsteon import devices
|
||||
from pyinsteon.config import (
|
||||
LOAD_BUTTON,
|
||||
@@ -18,7 +19,6 @@ from pyinsteon.constants import (
|
||||
)
|
||||
from pyinsteon.device_types.device_base import Device
|
||||
import voluptuous as vol
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,26 +43,26 @@ RELAY_MODES = [str(RelayMode(v)).lower() for v in list(RelayMode)]
|
||||
|
||||
|
||||
def _bool_schema(name):
|
||||
return voluptuous_serialize.convert(vol.Schema({vol.Required(name): bool}))[0]
|
||||
return serialize(vol.Schema({vol.Required(name): bool}))[0]
|
||||
|
||||
|
||||
def _byte_schema(name):
|
||||
return voluptuous_serialize.convert(vol.Schema({vol.Required(name): cv.byte}))[0]
|
||||
return serialize(vol.Schema({vol.Required(name): cv.byte}))[0]
|
||||
|
||||
|
||||
def _float_schema(name):
|
||||
return voluptuous_serialize.convert(vol.Schema({vol.Required(name): float}))[0]
|
||||
return serialize(vol.Schema({vol.Required(name): float}))[0]
|
||||
|
||||
|
||||
def _list_schema(name, values):
|
||||
return voluptuous_serialize.convert(
|
||||
return serialize(
|
||||
vol.Schema({vol.Required(name): vol.In(values)}),
|
||||
custom_serializer=cv.custom_serializer,
|
||||
)[0]
|
||||
|
||||
|
||||
def _multi_select_schema(name, values):
|
||||
return voluptuous_serialize.convert(
|
||||
return serialize(
|
||||
vol.Schema({vol.Optional(name): cv.multi_select(values)}),
|
||||
custom_serializer=cv.custom_serializer,
|
||||
)[0]
|
||||
@@ -70,7 +70,7 @@ def _multi_select_schema(name, values):
|
||||
|
||||
def _read_only_schema(name, value):
|
||||
"""Return a constant value schema."""
|
||||
return voluptuous_serialize.convert(vol.Schema({vol.Required(name): value}))[0]
|
||||
return serialize(vol.Schema({vol.Required(name): value}))[0]
|
||||
|
||||
|
||||
def get_schema(prop, name, groups):
|
||||
|
||||
@@ -49,7 +49,7 @@ CODE_SCHEMA = vol.Schema(
|
||||
|
||||
SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DOMAIN): vol.Exclusive(cv.string, "sensors"),
|
||||
vol.Optional(CONF_NAME, default=DOMAIN): cv.string,
|
||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
}
|
||||
@@ -57,7 +57,7 @@ SENSOR_SCHEMA = vol.Schema(
|
||||
|
||||
REMOTE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DOMAIN): vol.Exclusive(cv.string, "remotes"),
|
||||
vol.Optional(CONF_NAME, default=DOMAIN): cv.string,
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Selectors for KNX."""
|
||||
|
||||
from collections.abc import Hashable, Iterable
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
from typing import Any, override
|
||||
|
||||
@@ -23,7 +23,7 @@ class AllSerializeFirst(vol.All):
|
||||
class KNXSelectorBase:
|
||||
"""Base class for KNX selectors supporting optional nested schemas."""
|
||||
|
||||
schema: vol.Schema | vol.Any | vol.All
|
||||
schema: vol.Schema | vol.Any | vol.All | GroupSelectSchema
|
||||
selector_type: str
|
||||
# mark if self.schema should be serialized to `schema` key
|
||||
serialize_subschema: bool = False
|
||||
@@ -108,30 +108,35 @@ class GroupSelectOption(KNXSelectorBase):
|
||||
}
|
||||
|
||||
|
||||
class GroupSelectSchema(vol.Any):
|
||||
"""Use the first validated value.
|
||||
class GroupSelectSchema:
|
||||
"""Use the first validated value, like ``vol.Any``.
|
||||
|
||||
This is a version of vol.Any with custom error handling to
|
||||
show proper invalid markers for sub-schema items in the UI.
|
||||
A standalone validator rather than a ``vol.Any`` subclass, so it does not
|
||||
reach into validation-engine internals. On total failure it raises the most
|
||||
useful branch error (the first that is not an unknown-key error, else the
|
||||
first) so the UI marks a real problem instead of an extra key.
|
||||
"""
|
||||
|
||||
@override
|
||||
def _exec(self, funcs: Iterable, v: Any, path: list[Hashable] | None = None) -> Any:
|
||||
"""Execute the validation functions."""
|
||||
def __init__(self, *options: vol.Schemable, msg: str | None = None) -> None:
|
||||
"""Store the options to try in order."""
|
||||
self.validators = options
|
||||
self.msg = msg
|
||||
self._compiled = [vol.Schema(option) for option in options]
|
||||
|
||||
def __call__(self, data: Any) -> Any:
|
||||
"""Return the first option that validates, else raise the best error."""
|
||||
errors: list[vol.Invalid] = []
|
||||
for func in funcs:
|
||||
for option in self._compiled:
|
||||
try:
|
||||
if path is None:
|
||||
return func(v)
|
||||
return func(path, v)
|
||||
except vol.Invalid as e:
|
||||
errors.append(e)
|
||||
return option(data)
|
||||
except vol.Invalid as err:
|
||||
errors.append(err)
|
||||
if errors:
|
||||
raise next(
|
||||
(err for err in errors if "extra keys not allowed" not in err.msg),
|
||||
(err for err in errors if err.code != "extra_keys_not_allowed"),
|
||||
errors[0],
|
||||
)
|
||||
raise vol.AnyInvalid(self.msg or "no valid value found", path=path)
|
||||
raise vol.AnyInvalid(self.msg or "no valid value found")
|
||||
|
||||
|
||||
class GroupSelect(KNXSelectorBase):
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous_serialize import UNSUPPORTED, UnsupportedType, convert
|
||||
from probatio import UNSUPPORTED, serialize as convert
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.helpers import selector
|
||||
@@ -12,9 +11,7 @@ from .entity_store_schema import KNX_SCHEMA_FOR_PLATFORM
|
||||
from .knx_selector import AllSerializeFirst, GroupSelectSchema, KNXSelectorBase
|
||||
|
||||
|
||||
def knx_serializer(
|
||||
schema: vol.Schema,
|
||||
) -> dict[str, Any] | list[dict[str, Any]] | UnsupportedType:
|
||||
def knx_serializer(schema: Any) -> Any:
|
||||
"""Serialize KNX schema."""
|
||||
if isinstance(schema, GroupSelectSchema):
|
||||
return [
|
||||
@@ -43,5 +40,8 @@ def get_serialized_schema(
|
||||
) -> dict[str, Any] | list[dict[str, Any]] | None:
|
||||
"""Get the schema for a specific platform."""
|
||||
if knx_schema := KNX_SCHEMA_FOR_PLATFORM.get(platform):
|
||||
return convert(knx_schema, custom_serializer=knx_serializer)
|
||||
return cast(
|
||||
"dict[str, Any] | list[dict[str, Any]]",
|
||||
convert(knx_schema, custom_serializer=knx_serializer),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -66,7 +66,7 @@ LIFX_SET_STATE_SCHEMA: VolDictType = {
|
||||
SERVICE_LIFX_SET_HEV_CYCLE_STATE = "set_hev_cycle_state"
|
||||
|
||||
LIFX_SET_HEV_CYCLE_STATE_SCHEMA: VolDictType = {
|
||||
ATTR_POWER: vol.Required(cv.boolean),
|
||||
vol.Required(ATTR_POWER): cv.boolean,
|
||||
ATTR_DURATION: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=86400)),
|
||||
}
|
||||
|
||||
|
||||
@@ -178,9 +178,7 @@ LIFX_EFFECT_MORPH_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
**LIFX_EFFECT_SCHEMA,
|
||||
ATTR_SPEED: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=25)),
|
||||
vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.Optional(
|
||||
vol.In(ThemeLibrary().themes)
|
||||
),
|
||||
vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.In(ThemeLibrary().themes),
|
||||
vol.Exclusive(ATTR_PALETTE, COLOR_GROUP): vol.All(
|
||||
cv.ensure_list, [HSBK_SCHEMA]
|
||||
),
|
||||
@@ -192,7 +190,7 @@ LIFX_EFFECT_MOVE_SCHEMA = cv.make_entity_service_schema(
|
||||
**LIFX_EFFECT_SCHEMA,
|
||||
ATTR_SPEED: vol.All(vol.Coerce(float), vol.Clamp(min=0.1, max=60)),
|
||||
ATTR_DIRECTION: vol.In(EFFECT_MOVE_DIRECTIONS),
|
||||
ATTR_THEME: vol.Optional(vol.In(ThemeLibrary().themes)),
|
||||
vol.Optional(ATTR_THEME): vol.In(ThemeLibrary().themes),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -211,9 +209,7 @@ LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
**LIFX_EFFECT_SCHEMA,
|
||||
ATTR_TRANSITION: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3600)),
|
||||
vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.Optional(
|
||||
vol.In(ThemeLibrary().themes)
|
||||
),
|
||||
vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.In(ThemeLibrary().themes),
|
||||
vol.Exclusive(ATTR_PALETTE, COLOR_GROUP): vol.All(
|
||||
cv.ensure_list, [HSBK_SCHEMA]
|
||||
),
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==13.2.5"]
|
||||
"requirements": ["ical==13.3.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==13.2.5"]
|
||||
"requirements": ["ical==13.3.0"]
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ from mcp import McpError
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from probatio import from_openapi as convert_to_voluptuous
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert_to_voluptuous
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
|
||||
@@ -15,9 +15,9 @@ from typing import Any, cast
|
||||
from mcp import types
|
||||
from mcp.server import Server
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from probatio import to_openapi as convert
|
||||
from pydantic import AnyUrl
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateCrossedThresholdTriggerBase,
|
||||
EntityNumericalStateTriggerBase,
|
||||
EntityTriggerBase,
|
||||
NotTriggeredReasonReporter,
|
||||
Trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
@@ -61,11 +60,7 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
|
||||
return self.is_muted(from_state) != self.is_muted(to_state)
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected state."""
|
||||
if not self._has_volume_attributes(state):
|
||||
return False
|
||||
|
||||
@@ -14,6 +14,7 @@ from aiohttp.web import HTTPBadRequest, Request, Response, json_response
|
||||
from nacl.exceptions import CryptoError
|
||||
from nacl.secret import SecretBox
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant.components import (
|
||||
camera,
|
||||
@@ -159,7 +160,7 @@ def validate_schema(schema):
|
||||
try:
|
||||
data = schema(data)
|
||||
except vol.Invalid as ex:
|
||||
err = vol.humanize.humanize_error(data, ex)
|
||||
err = humanize_error(data, ex)
|
||||
_LOGGER.error("Received invalid webhook payload: %s", err)
|
||||
return empty_okay_response()
|
||||
|
||||
@@ -200,7 +201,7 @@ async def handle_webhook(
|
||||
try:
|
||||
req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data)
|
||||
except vol.Invalid as ex:
|
||||
err = vol.humanize.humanize_error(req_data, ex)
|
||||
err = humanize_error(req_data, ex)
|
||||
_LOGGER.error(
|
||||
"Received invalid webhook from %s with payload: %s", device_name, err
|
||||
)
|
||||
@@ -648,7 +649,7 @@ async def webhook_update_sensor_states(
|
||||
try:
|
||||
sensor = SENSOR_SCHEMA_FULL(sensor)
|
||||
except vol.Invalid as err:
|
||||
err_msg = vol.humanize.humanize_error(sensor, err)
|
||||
err_msg = humanize_error(sensor, err)
|
||||
_LOGGER.error(
|
||||
"Received invalid sensor payload from %s for %s: %s",
|
||||
device_name,
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
},
|
||||
"get_queue": {
|
||||
"description": "Retrieves the details of the currently active queue of a Music Assistant player.",
|
||||
"name": "Get playerQueue details (advanced)"
|
||||
"name": "Get playerQueue details"
|
||||
},
|
||||
"play_announcement": {
|
||||
"description": "Plays an announcement on a Music Assistant player with more fine-grained control options.",
|
||||
|
||||
@@ -267,7 +267,7 @@ SWITCHES = (
|
||||
),
|
||||
NextDnsSwitchEntityDescription(
|
||||
key="block_hulu",
|
||||
name="Block Hulu",
|
||||
translation_key="block_hulu",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
state=lambda data: data.block_hulu,
|
||||
|
||||
@@ -6,8 +6,8 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import ollama
|
||||
from probatio import to_openapi as convert
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
|
||||
@@ -22,8 +22,8 @@ from openai.types.chat import (
|
||||
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
|
||||
from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema
|
||||
from openai.types.shared_params.response_format_json_schema import JSONSchema
|
||||
from probatio import to_openapi as convert
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
|
||||
@@ -6,8 +6,8 @@ import logging
|
||||
from typing import Any, override
|
||||
|
||||
import openai
|
||||
from probatio import to_openapi as convert
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components.zone import ENTITY_ID_HOME
|
||||
from homeassistant.config_entries import (
|
||||
@@ -326,7 +326,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
options.update(user_input)
|
||||
if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input:
|
||||
options.pop(CONF_LLM_HASS_API)
|
||||
return await self.async_step_advanced()
|
||||
return await self.async_step_additional()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
@@ -335,10 +335,10 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_advanced(
|
||||
async def async_step_additional(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Manage advanced options."""
|
||||
"""Manage additional options."""
|
||||
options = self.options
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
@@ -374,7 +374,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
return await self.async_step_model()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="advanced",
|
||||
step_id="additional",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(step_schema), options
|
||||
),
|
||||
|
||||
@@ -56,8 +56,8 @@ from openai.types.responses.tool_param import (
|
||||
ImageGeneration,
|
||||
)
|
||||
from openai.types.responses.web_search_tool_param import UserLocation
|
||||
from probatio import to_openapi as convert
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
|
||||
@@ -47,18 +47,18 @@
|
||||
"user": "Add AI task"
|
||||
},
|
||||
"step": {
|
||||
"advanced": {
|
||||
"additional": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]",
|
||||
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::store_responses%]",
|
||||
"temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]",
|
||||
"top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]"
|
||||
"max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data::max_tokens%]",
|
||||
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data::store_responses%]",
|
||||
"temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data::temperature%]",
|
||||
"top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data::top_p%]"
|
||||
},
|
||||
"data_description": {
|
||||
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data_description::store_responses%]"
|
||||
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data_description::store_responses%]"
|
||||
},
|
||||
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]"
|
||||
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::title%]"
|
||||
},
|
||||
"init": {
|
||||
"data": {
|
||||
@@ -109,7 +109,7 @@
|
||||
"user": "Add conversation agent"
|
||||
},
|
||||
"step": {
|
||||
"advanced": {
|
||||
"additional": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
@@ -120,7 +120,7 @@
|
||||
"data_description": {
|
||||
"store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs"
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
"title": "Additional settings"
|
||||
},
|
||||
"init": {
|
||||
"data": {
|
||||
|
||||
@@ -18,7 +18,7 @@ from openai.types.chat import (
|
||||
)
|
||||
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
|
||||
from openai.types.shared_params import FunctionDefinition
|
||||
from voluptuous_openapi import convert
|
||||
from probatio import to_openapi as convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
|
||||
@@ -34,6 +34,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
from .util import sanitize_container_name
|
||||
|
||||
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
|
||||
|
||||
@@ -263,7 +264,7 @@ class PortainerCoordinator(
|
||||
|
||||
# Map containers, started and stopped
|
||||
for container in containers:
|
||||
container_name = self._get_container_name(container.names[0])
|
||||
container_name = sanitize_container_name(container.names[0])
|
||||
prev_container = (
|
||||
prev_endpoint.containers.get(container_name)
|
||||
if prev_endpoint
|
||||
@@ -313,7 +314,7 @@ class PortainerCoordinator(
|
||||
container_stats = dict(
|
||||
zip(
|
||||
(
|
||||
self._get_container_name(container.names[0])
|
||||
sanitize_container_name(container.names[0])
|
||||
for container in active_containers
|
||||
),
|
||||
await asyncio.gather(
|
||||
@@ -431,10 +432,6 @@ class PortainerCoordinator(
|
||||
for stack_callback in self.new_stacks_callbacks:
|
||||
stack_callback(new_stack_data)
|
||||
|
||||
def _get_container_name(self, container_name: str) -> str:
|
||||
"""Sanitize to get a proper container name."""
|
||||
return container_name.replace("/", " ").strip()
|
||||
|
||||
|
||||
class PortainerDockerDiskSpaceCoordinator(
|
||||
PortainerBaseCoordinator[dict[int, DockerSystemDF]]
|
||||
|
||||
@@ -19,6 +19,7 @@ from .coordinator import (
|
||||
PortainerStackData,
|
||||
PortainerVolumeData,
|
||||
)
|
||||
from .util import sanitize_container_name
|
||||
|
||||
|
||||
class PortainerCoordinatorEntity(CoordinatorEntity[PortainerCoordinator]):
|
||||
@@ -95,7 +96,7 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
|
||||
# According to Docker's API docs, the first name is unique
|
||||
names = self._device_info.container.names
|
||||
assert names, "Container names list unexpectedly empty"
|
||||
self.device_name = names[0].replace("/", " ").strip()
|
||||
self.device_name = sanitize_container_name(names[0])
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Utility functions for the Portainer integration."""
|
||||
|
||||
|
||||
def sanitize_container_name(container_name: str) -> str:
|
||||
"""Sanitize to get a proper container name."""
|
||||
return container_name.replace("/", " ").strip()
|
||||
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any, override
|
||||
from pushbullet import PushBullet, PushError
|
||||
from pushbullet.channel import Channel
|
||||
from pushbullet.device import Device
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
@@ -144,7 +143,7 @@ class PushBulletNotificationService(BaseNotificationService):
|
||||
raise ValueError("Cannot send an empty file")
|
||||
kwargs.update(filedata)
|
||||
pusher.push_file(**kwargs)
|
||||
elif (file_url := data.get(ATTR_FILE_URL)) and vol.Url(file_url):
|
||||
elif file_url := data.get(ATTR_FILE_URL):
|
||||
pusher.push_file(
|
||||
file_name=file_url,
|
||||
file_url=file_url,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==13.2.5"]
|
||||
"requirements": ["ical==13.3.0"]
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"timeout": "Timeout for connection to website.",
|
||||
"verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed."
|
||||
},
|
||||
"description": "Provide additional advanced settings for the resource.",
|
||||
"name": "Advanced settings"
|
||||
"description": "Provide additional settings for the resource.",
|
||||
"name": "Additional settings"
|
||||
},
|
||||
"auth": {
|
||||
"data": {
|
||||
@@ -117,8 +117,8 @@
|
||||
"unit_of_measurement": "Choose unit of measurement or create your own.",
|
||||
"value_template": "Defines a template to get the state of the sensor."
|
||||
},
|
||||
"description": "Provide additional advanced settings for the sensor.",
|
||||
"name": "Advanced settings"
|
||||
"description": "Provide additional settings for the sensor.",
|
||||
"name": "Additional settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
"""SMLIGHT SLZB Zigbee device integration."""
|
||||
"""SMLIGHT SLZB device integration."""
|
||||
|
||||
from pysmlight import Api2
|
||||
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .bluetooth import async_connect_scanner
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
SmConfigEntry,
|
||||
SmDataUpdateCoordinator,
|
||||
SmFirmwareUpdateCoordinator,
|
||||
SmlightData,
|
||||
base_device_info,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -37,7 +39,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
|
||||
"""Set up SMLIGHT Zigbee from a config entry."""
|
||||
"""Set up SMLIGHT from a config entry."""
|
||||
client = Api2(host=entry.data[CONF_HOST], session=async_get_clientsession(hass))
|
||||
|
||||
data_coordinator = SmDataUpdateCoordinator(hass, entry, client)
|
||||
@@ -46,13 +48,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
|
||||
await data_coordinator.async_config_entry_first_refresh()
|
||||
await firmware_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
if data_coordinator.data.info.legacy_api < 2:
|
||||
info = data_coordinator.data.info
|
||||
|
||||
if info.legacy_api < 2:
|
||||
entry.async_create_background_task(
|
||||
hass, client.sse.client(), "smlight-sse-client"
|
||||
)
|
||||
|
||||
if info.ble is not None and info.ble.proxy_enabled:
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
**base_device_info(info, client.host),
|
||||
)
|
||||
entry.async_on_unload(async_connect_scanner(hass, entry, info.model, device.id))
|
||||
|
||||
entry.runtime_data = SmlightData(
|
||||
data=data_coordinator, firmware=firmware_coordinator
|
||||
data=data_coordinator,
|
||||
firmware=firmware_coordinator,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
@@ -60,5 +73,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
"""Unload SMLIGHT config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Bluetooth proxy for SLZB devices using bleak-smlight."""
|
||||
|
||||
from functools import partial
|
||||
|
||||
from bleak_smlight import SLZB_BLE_SERVER_PORT, connect_scanner
|
||||
from pysmlight import BleProxyClient
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
async_register_scanner,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SmConfigEntry
|
||||
|
||||
|
||||
@callback
|
||||
def _async_unload(
|
||||
unload_callbacks: list[CALLBACK_TYPE],
|
||||
client: BleProxyClient,
|
||||
) -> None:
|
||||
"""Unload callbacks and stop client."""
|
||||
for callback_func in unload_callbacks:
|
||||
callback_func()
|
||||
client.stop()
|
||||
|
||||
|
||||
@callback
|
||||
def async_connect_scanner(
|
||||
hass: HomeAssistant,
|
||||
entry: SmConfigEntry,
|
||||
model: str | None,
|
||||
device_id: str,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Connect scanner using the external bleak-smlight backend."""
|
||||
assert entry.unique_id is not None
|
||||
|
||||
client_data = connect_scanner(
|
||||
source=entry.unique_id,
|
||||
name=entry.title,
|
||||
host=entry.data[CONF_HOST],
|
||||
port=SLZB_BLE_SERVER_PORT,
|
||||
)
|
||||
|
||||
client_data.scanner.async_set_scanning_mode(BluetoothScanningMode.AUTO)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
client_data.client.start(),
|
||||
f"smlight-ble-proxy-client-{entry.unique_id}",
|
||||
)
|
||||
|
||||
unload_callbacks = [
|
||||
async_register_scanner(
|
||||
hass,
|
||||
client_data.scanner,
|
||||
source_domain=DOMAIN,
|
||||
source_model=model,
|
||||
source_config_entry_id=entry.entry_id,
|
||||
source_device_id=device_id,
|
||||
),
|
||||
client_data.scanner.async_setup(),
|
||||
]
|
||||
|
||||
return partial(_async_unload, unload_callbacks, client_data.client)
|
||||
@@ -15,11 +15,21 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_FIRMWARE_INTERVAL, SCAN_INTERVAL
|
||||
from .const import (
|
||||
ATTR_MANUFACTURER,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
SCAN_FIRMWARE_INTERVAL,
|
||||
SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
@@ -50,6 +60,17 @@ class SmFwData:
|
||||
type SmConfigEntry = ConfigEntry[SmlightData]
|
||||
|
||||
|
||||
def base_device_info(info: Info, host: str) -> DeviceInfo:
|
||||
"""Return device registry information."""
|
||||
return DeviceInfo(
|
||||
configuration_url=f"http://{host}",
|
||||
connections={(CONNECTION_NETWORK_MAC, str(info.MAC))},
|
||||
manufacturer=ATTR_MANUFACTURER,
|
||||
model=info.model,
|
||||
sw_version=f"core: {info.sw_version} / zigbee: {info.zb_version}",
|
||||
)
|
||||
|
||||
|
||||
class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
"""Base Coordinator for SMLIGHT."""
|
||||
|
||||
@@ -93,6 +114,7 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
info = await self.client.get_info()
|
||||
self.unique_id = format_mac(info.MAC)
|
||||
self.legacy_api = info.legacy_api
|
||||
|
||||
if info.legacy_api == 2:
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
"""Base class for all SMLIGHT entities."""
|
||||
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import ATTR_MANUFACTURER
|
||||
from .coordinator import SmBaseDataUpdateCoordinator
|
||||
from .coordinator import SmBaseDataUpdateCoordinator, base_device_info
|
||||
|
||||
|
||||
class SmEntity(CoordinatorEntity[SmBaseDataUpdateCoordinator]):
|
||||
@@ -19,14 +13,6 @@ class SmEntity(CoordinatorEntity[SmBaseDataUpdateCoordinator]):
|
||||
def __init__(self, coordinator: SmBaseDataUpdateCoordinator) -> None:
|
||||
"""Initialize entity with device."""
|
||||
super().__init__(coordinator)
|
||||
mac = format_mac(coordinator.data.info.MAC)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url=f"http://{coordinator.client.host}",
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
manufacturer=ATTR_MANUFACTURER,
|
||||
model=coordinator.data.info.model,
|
||||
sw_version=(
|
||||
f"core: {coordinator.data.info.sw_version}"
|
||||
f" / zigbee: {coordinator.data.info.zb_version}"
|
||||
),
|
||||
self._attr_device_info = base_device_info(
|
||||
coordinator.data.info, coordinator.client.host
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "SMLIGHT SLZB",
|
||||
"codeowners": ["@tl-sl"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth"],
|
||||
"dhcp": [
|
||||
{
|
||||
"registered_devices": true
|
||||
@@ -12,7 +13,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pysmlight==0.5.0"],
|
||||
"requirements": ["pysmlight==0.5.0", "bleak-smlight==1.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_slzb-06._tcp.local."
|
||||
|
||||
@@ -39,23 +39,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove both the `{domain}` and the relevant Modbus configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"title": "The {integration_title} YAML configuration is being removed"
|
||||
},
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually.",
|
||||
"title": "YAML import failed due to a connection error"
|
||||
},
|
||||
"deprecated_yaml_import_issue_missing_hub": {
|
||||
"description": "Configuring {integration_title} using YAML is being removed but the configuration was not complete, thus we could not import your configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually.",
|
||||
"title": "YAML import failed due to incomplete config"
|
||||
},
|
||||
"deprecated_yaml_import_issue_unknown": {
|
||||
"description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually.",
|
||||
"title": "YAML import failed due to an unknown error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ PLATFORMS_BY_TYPE = {
|
||||
Platform.SENSOR,
|
||||
],
|
||||
SupportedModels.WEATHER_STATION.value: [Platform.SENSOR],
|
||||
SupportedModels.CANDLE_WARMER_LAMP.value: [Platform.LIGHT, Platform.SENSOR],
|
||||
}
|
||||
CLASS_BY_DEVICE = {
|
||||
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
|
||||
@@ -245,6 +246,7 @@ CLASS_BY_DEVICE = {
|
||||
SupportedModels.LOCK_VISION_PRO.value: switchbot.SwitchbotLock,
|
||||
SupportedModels.LOCK_VISION.value: switchbot.SwitchbotLock,
|
||||
SupportedModels.LOCK_PRO_WIFI.value: switchbot.SwitchbotLock,
|
||||
SupportedModels.CANDLE_WARMER_LAMP.value: switchbot.SwitchbotCandleWarmerLamp,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ class SupportedModels(StrEnum):
|
||||
LOCK_PRO_WIFI = "lock_pro_wifi"
|
||||
WEATHER_STATION = "weather_station"
|
||||
STANDING_FAN = "standing_fan"
|
||||
CANDLE_WARMER_LAMP = "candle_warmer_lamp"
|
||||
|
||||
|
||||
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -122,6 +123,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.LOCK_VISION: SupportedModels.LOCK_VISION,
|
||||
SwitchbotModel.LOCK_PRO_WIFI: SupportedModels.LOCK_PRO_WIFI,
|
||||
SwitchbotModel.STANDING_FAN: SupportedModels.STANDING_FAN,
|
||||
SwitchbotModel.CANDLE_WARMER_LAMP: SupportedModels.CANDLE_WARMER_LAMP,
|
||||
}
|
||||
|
||||
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -171,6 +173,7 @@ ENCRYPTED_MODELS = {
|
||||
SwitchbotModel.LOCK_VISION_PRO,
|
||||
SwitchbotModel.LOCK_VISION,
|
||||
SwitchbotModel.LOCK_PRO_WIFI,
|
||||
SwitchbotModel.CANDLE_WARMER_LAMP,
|
||||
}
|
||||
|
||||
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
|
||||
@@ -204,6 +207,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
|
||||
SwitchbotModel.LOCK_VISION_PRO: switchbot.SwitchbotLock,
|
||||
SwitchbotModel.LOCK_VISION: switchbot.SwitchbotLock,
|
||||
SwitchbotModel.LOCK_PRO_WIFI: switchbot.SwitchbotLock,
|
||||
SwitchbotModel.CANDLE_WARMER_LAMP: switchbot.SwitchbotCandleWarmerLamp,
|
||||
}
|
||||
|
||||
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {
|
||||
|
||||
@@ -26,6 +26,7 @@ from .entity import SwitchbotEntity, exception_handler
|
||||
SWITCHBOT_COLOR_MODE_TO_HASS = {
|
||||
SwitchBotColorMode.RGB: ColorMode.RGB,
|
||||
SwitchBotColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP,
|
||||
SwitchBotColorMode.BRIGHTNESS: ColorMode.BRIGHTNESS,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,7 +53,7 @@ from .const import (
|
||||
PLATFORM_BROADCAST,
|
||||
PLATFORM_POLLING,
|
||||
PLATFORM_WEBHOOKS,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS,
|
||||
SUBENTRY_TYPE_ALLOWED_CHAT_IDS,
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ DESCRIPTION_PLACEHOLDERS: dict[str, str] = {
|
||||
"id_bot_username": "@id_bot",
|
||||
"id_bot_url": "https://t.me/id_bot",
|
||||
"socks_url": "socks5://username:password@proxy_ip:proxy_port",
|
||||
# used in advanced settings section
|
||||
# used in additional settings section
|
||||
"default_api_endpoint": DEFAULT_API_ENDPOINT,
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
@@ -117,7 +117,7 @@ STEP_RECONFIGURE_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
|
||||
translation_key="platforms",
|
||||
)
|
||||
),
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
@@ -241,10 +241,10 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# validate connection to Telegram API
|
||||
errors: dict[str, str] = {}
|
||||
user_input[CONF_API_ENDPOINT] = user_input[SECTION_ADVANCED_SETTINGS][
|
||||
user_input[CONF_API_ENDPOINT] = user_input[SECTION_ADDITIONAL_SETTINGS][
|
||||
CONF_API_ENDPOINT
|
||||
]
|
||||
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
|
||||
user_input[CONF_PROXY_URL] = user_input[SECTION_ADDITIONAL_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
)
|
||||
bot_name = await self._validate_bot(
|
||||
@@ -270,9 +270,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PLATFORM: user_input[CONF_PLATFORM],
|
||||
CONF_API_ENDPOINT: user_input[CONF_API_ENDPOINT],
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
CONF_PROXY_URL: user_input[SECTION_ADVANCED_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
),
|
||||
CONF_PROXY_URL: user_input[CONF_PROXY_URL],
|
||||
},
|
||||
options={ATTR_PARSER: PARSER_MD},
|
||||
description_placeholders=description_placeholders,
|
||||
@@ -383,10 +381,10 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
CONF_PLATFORM: self._step_user_data[CONF_PLATFORM],
|
||||
CONF_API_KEY: self._step_user_data[CONF_API_KEY],
|
||||
CONF_API_ENDPOINT: self._step_user_data[SECTION_ADVANCED_SETTINGS][
|
||||
CONF_API_ENDPOINT: self._step_user_data[SECTION_ADDITIONAL_SETTINGS][
|
||||
CONF_API_ENDPOINT
|
||||
],
|
||||
CONF_PROXY_URL: self._step_user_data[SECTION_ADVANCED_SETTINGS].get(
|
||||
CONF_PROXY_URL: self._step_user_data[SECTION_ADDITIONAL_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
),
|
||||
CONF_URL: user_input.get(CONF_URL),
|
||||
@@ -461,7 +459,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
STEP_RECONFIGURE_USER_DATA_SCHEMA,
|
||||
{
|
||||
**self._get_reconfigure_entry().data,
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_API_ENDPOINT: self._get_reconfigure_entry().data[
|
||||
CONF_API_ENDPOINT
|
||||
],
|
||||
@@ -473,11 +471,11 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
description_placeholders=DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
|
||||
user_input[CONF_PROXY_URL] = user_input[SECTION_ADDITIONAL_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
)
|
||||
|
||||
user_input[CONF_API_ENDPOINT] = user_input[SECTION_ADVANCED_SETTINGS][
|
||||
user_input[CONF_API_ENDPOINT] = user_input[SECTION_ADDITIONAL_SETTINGS][
|
||||
CONF_API_ENDPOINT
|
||||
]
|
||||
|
||||
@@ -528,7 +526,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
STEP_RECONFIGURE_USER_DATA_SCHEMA,
|
||||
{
|
||||
**user_input,
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_API_ENDPOINT: user_input[CONF_API_ENDPOINT],
|
||||
CONF_PROXY_URL: user_input.get(CONF_PROXY_URL),
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ DOMAIN = "telegram_bot"
|
||||
PLATFORM_BROADCAST = "broadcast"
|
||||
PLATFORM_POLLING = "polling"
|
||||
PLATFORM_WEBHOOKS = "webhooks"
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
SECTION_ADDITIONAL_SETTINGS = "additional_settings"
|
||||
SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids"
|
||||
|
||||
CONF_ALLOWED_CHAT_IDS = "allowed_chat_ids"
|
||||
|
||||
@@ -33,16 +33,16 @@
|
||||
},
|
||||
"description": "Reconfigure Telegram bot",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"additional_settings": {
|
||||
"data": {
|
||||
"api_endpoint": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::api_endpoint%]",
|
||||
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::proxy_url%]"
|
||||
"api_endpoint": "[%key:component::telegram_bot::config::step::user::sections::additional_settings::data::api_endpoint%]",
|
||||
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::additional_settings::data::proxy_url%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_endpoint": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::api_endpoint%]",
|
||||
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::proxy_url%]"
|
||||
"api_endpoint": "[%key:component::telegram_bot::config::step::user::sections::additional_settings::data_description::api_endpoint%]",
|
||||
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::additional_settings::data_description::proxy_url%]"
|
||||
},
|
||||
"name": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::name%]"
|
||||
"name": "[%key:component::telegram_bot::config::step::user::sections::additional_settings::name%]"
|
||||
}
|
||||
},
|
||||
"title": "Telegram bot setup"
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"additional_settings": {
|
||||
"data": {
|
||||
"api_endpoint": "API endpoint",
|
||||
"proxy_url": "Proxy URL"
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["verisure"],
|
||||
"requirements": ["vsure==2.7.1"]
|
||||
"requirements": ["vsure==2.8.0"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal, override
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant.auth.models import RefreshToken, User
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
@@ -295,7 +296,7 @@ class ActiveConnection:
|
||||
err_message = "Unauthorized"
|
||||
elif isinstance(err, vol.Invalid):
|
||||
code = const.ERR_INVALID_FORMAT
|
||||
err_message = vol.humanize.humanize_error(msg, err)
|
||||
err_message = humanize_error(msg, err)
|
||||
elif isinstance(err, TimeoutError):
|
||||
code = const.ERR_TIMEOUT
|
||||
err_message = "Timeout"
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"migration_strategy_recommended": "This is the quickest option to migrate to a new adapter."
|
||||
},
|
||||
"menu_options": {
|
||||
"migration_strategy_advanced": "Advanced migration",
|
||||
"migration_strategy_advanced": "Migrate manually",
|
||||
"migration_strategy_recommended": "Migrate automatically (recommended)"
|
||||
},
|
||||
"title": "Migrate to a new adapter"
|
||||
@@ -74,7 +74,7 @@
|
||||
"setup_strategy_recommended": "This is the quickest option to create a new network and get started."
|
||||
},
|
||||
"menu_options": {
|
||||
"setup_strategy_advanced": "Advanced setup",
|
||||
"setup_strategy_advanced": "Set up manually",
|
||||
"setup_strategy_recommended": "Set up automatically (recommended)"
|
||||
},
|
||||
"title": "Set up Zigbee"
|
||||
|
||||
@@ -779,7 +779,7 @@ async def websocket_device_cluster_commands(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Return a list of cluster commands."""
|
||||
import voluptuous_serialize # noqa: PLC0415
|
||||
from probatio import serialize # noqa: PLC0415
|
||||
|
||||
zha_gateway = get_zha_gateway(hass)
|
||||
ieee: EUI64 = msg[ATTR_IEEE]
|
||||
@@ -801,7 +801,7 @@ async def websocket_device_cluster_commands(
|
||||
TYPE: CLIENT,
|
||||
ID: cmd_id,
|
||||
ATTR_NAME: cmd.name,
|
||||
"schema": voluptuous_serialize.convert(
|
||||
"schema": serialize(
|
||||
cluster_command_schema_to_vol_schema(cmd.schema),
|
||||
custom_serializer=cv.custom_serializer,
|
||||
),
|
||||
@@ -813,7 +813,7 @@ async def websocket_device_cluster_commands(
|
||||
TYPE: CLUSTER_COMMAND_SERVER,
|
||||
ID: cmd_id,
|
||||
ATTR_NAME: cmd.name,
|
||||
"schema": voluptuous_serialize.convert(
|
||||
"schema": serialize(
|
||||
cluster_command_schema_to_vol_schema(cmd.schema),
|
||||
custom_serializer=cv.custom_serializer,
|
||||
),
|
||||
@@ -1087,16 +1087,14 @@ async def websocket_get_configuration(
|
||||
) -> None:
|
||||
"""Get ZHA configuration."""
|
||||
config_entry: ConfigEntry = get_config_entry(hass)
|
||||
import voluptuous_serialize # noqa: PLC0415
|
||||
from probatio import serialize # noqa: PLC0415
|
||||
|
||||
def custom_serializer(schema: Any) -> Any:
|
||||
"""Serialize additional types for voluptuous_serialize."""
|
||||
"""Serialize additional types for the field-list serializer."""
|
||||
if schema is cv_boolean:
|
||||
return {"type": "bool"}
|
||||
if schema is vol.Schema:
|
||||
return voluptuous_serialize.convert(
|
||||
schema, custom_serializer=custom_serializer
|
||||
)
|
||||
return serialize(schema, custom_serializer=custom_serializer)
|
||||
|
||||
return cv.custom_serializer(schema)
|
||||
|
||||
@@ -1106,7 +1104,7 @@ async def websocket_get_configuration(
|
||||
hass, IasAce.cluster_id
|
||||
):
|
||||
continue
|
||||
data["schemas"][section] = voluptuous_serialize.convert(
|
||||
data["schemas"][section] = serialize(
|
||||
schema, custom_serializer=custom_serializer
|
||||
)
|
||||
data["data"][section] = config_entry.options.get(CUSTOM_CONFIGURATION, {}).get(
|
||||
|
||||
@@ -36,7 +36,6 @@ from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityTriggerBase,
|
||||
NotTriggeredReasonReporter,
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
@@ -212,11 +211,7 @@ class EnteredZoneTrigger(ZoneTriggerBase):
|
||||
return not self._in_target_zone(from_state)
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the entity is now in the selected zone."""
|
||||
return self._in_target_zone(state)
|
||||
|
||||
@@ -230,11 +225,7 @@ class LeftZoneTrigger(ZoneTriggerBase):
|
||||
return self._in_target_zone(from_state)
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the entity is no longer in the selected zone."""
|
||||
return not self._in_target_zone(state)
|
||||
|
||||
@@ -288,11 +279,7 @@ class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase):
|
||||
"""Trigger when a zone transitions to an occupied state."""
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the zone is occupied."""
|
||||
return self._is_occupied(state)
|
||||
|
||||
@@ -306,11 +293,7 @@ class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase):
|
||||
"""Trigger when a zone transitions from occupied to unoccupied."""
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the zone is empty (count == 0)."""
|
||||
return self._occupancy_count(state) == 0
|
||||
|
||||
|
||||
@@ -478,7 +478,7 @@ def stringify_invalid(
|
||||
if annotation := find_annotation(config, exc.path):
|
||||
message_prefix += f" at {_relpath(hass, annotation[0])}, line {annotation[1]}"
|
||||
path = "->".join(str(m) for m in exc.path)
|
||||
if exc.error_message == "extra keys not allowed":
|
||||
if exc.code == "extra_keys_not_allowed":
|
||||
return (
|
||||
f"{message_prefix}: '{exc.path[-1]}' is an invalid option for '{domain}', "
|
||||
f"check: {path}{message_suffix}"
|
||||
|
||||
@@ -23,8 +23,8 @@ from typing import TYPE_CHECKING, Any, cast, overload
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from probatio import UNSUPPORTED, serialize
|
||||
import voluptuous as vol
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
@@ -1188,7 +1188,7 @@ def _custom_serializer(schema: Any, *, allow_section: bool) -> Any:
|
||||
raise ValueError("Nesting expandable sections is not supported")
|
||||
return {
|
||||
"type": "expandable",
|
||||
"schema": voluptuous_serialize.convert(
|
||||
"schema": serialize(
|
||||
schema.schema,
|
||||
custom_serializer=functools.partial(
|
||||
_custom_serializer, allow_section=False
|
||||
@@ -1203,7 +1203,7 @@ def _custom_serializer(schema: Any, *, allow_section: bool) -> Any:
|
||||
if isinstance(schema, selector.Selector):
|
||||
return schema.serialize()
|
||||
|
||||
return voluptuous_serialize.UNSUPPORTED
|
||||
return UNSUPPORTED
|
||||
|
||||
|
||||
# Schemas
|
||||
|
||||
@@ -4,8 +4,8 @@ from http import HTTPStatus
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from aiohttp import web
|
||||
from probatio import serialize
|
||||
import voluptuous as vol
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
@@ -47,7 +47,7 @@ class _BaseFlowManagerView(HomeAssistantView, Generic[_FlowManagerT]):
|
||||
if (schema := result["data_schema"]) is None:
|
||||
data["data_schema"] = []
|
||||
else:
|
||||
data["data_schema"] = voluptuous_serialize.convert(
|
||||
data["data_schema"] = serialize(
|
||||
schema, custom_serializer=cv.custom_serializer
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -10,9 +10,9 @@ from functools import cache, partial
|
||||
from operator import attrgetter
|
||||
from typing import Any, cast, override
|
||||
|
||||
from probatio import UNSUPPORTED, to_openapi as convert
|
||||
import slugify as unicode_slug
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import UNSUPPORTED, convert
|
||||
|
||||
from homeassistant.components.calendar import (
|
||||
DOMAIN as CALENDAR_DOMAIN,
|
||||
|
||||
@@ -373,10 +373,6 @@ ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def _report_not_triggered_noop(reason: str, /, **data: Any) -> None:
|
||||
"""Swallow a not-triggered report; used when diagnostics are not wanted."""
|
||||
|
||||
|
||||
class EntityTriggerBase(Trigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
@@ -434,11 +430,7 @@ class EntityTriggerBase(Trigger):
|
||||
"""
|
||||
return from_state.state != to_state.state
|
||||
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state is a target state for the trigger.
|
||||
|
||||
Called only after `state.state` has been filtered against
|
||||
@@ -446,12 +438,6 @@ class EntityTriggerBase(Trigger):
|
||||
check. Default: any non-excluded state is a target. Override
|
||||
to restrict (specific to_states, value within a threshold,
|
||||
etc.).
|
||||
|
||||
When the state cannot fire the trigger, subclasses may use
|
||||
`report_not_triggered` to record an interesting reason - e.g. a
|
||||
non-numeric value or an unsupported unit - in the automation trace.
|
||||
It defaults to a no-op, so callers that don't collect diagnostics
|
||||
(e.g. `count_matches`) can omit it.
|
||||
"""
|
||||
return True
|
||||
|
||||
@@ -494,7 +480,7 @@ class EntityTriggerBase(Trigger):
|
||||
if state is None or not self._should_include(state):
|
||||
continue
|
||||
included += 1
|
||||
if self.is_valid_state(state, _report_not_triggered_noop):
|
||||
if self.is_valid_state(state):
|
||||
matches += 1
|
||||
return matches, included
|
||||
|
||||
@@ -522,7 +508,7 @@ class EntityTriggerBase(Trigger):
|
||||
if (
|
||||
to_state is None
|
||||
or to_state.state in self._excluded_states
|
||||
or not self.is_valid_state(to_state, _report_not_triggered_noop)
|
||||
or not self.is_valid_state(to_state)
|
||||
):
|
||||
pending_timers.pop(entity_id)()
|
||||
return
|
||||
@@ -606,19 +592,11 @@ class EntityTriggerBase(Trigger):
|
||||
if not from_state or not to_state:
|
||||
return
|
||||
|
||||
if to_state.state in self._excluded_states:
|
||||
return
|
||||
|
||||
@callback
|
||||
def report_not_triggered(reason: str, /, **data: Any) -> None:
|
||||
"""Report why this evaluated change did not fire the trigger."""
|
||||
if did_not_trigger is None:
|
||||
return
|
||||
did_not_trigger(
|
||||
NotTriggeredInfo(reason=reason, data=data), event.context
|
||||
)
|
||||
|
||||
if not self.is_valid_state(to_state, report_not_triggered):
|
||||
# The trigger should never fire if the new state is excluded
|
||||
# or not a target state.
|
||||
if to_state.state in self._excluded_states or not self.is_valid_state(
|
||||
to_state
|
||||
):
|
||||
return
|
||||
|
||||
# The trigger should never fire if the origin state is excluded
|
||||
@@ -729,11 +707,7 @@ class EntityTargetStateTriggerBase(EntityTriggerBase):
|
||||
)
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected state."""
|
||||
return self._get_tracked_value(state) in self._to_states
|
||||
|
||||
@@ -754,11 +728,7 @@ class EntityTransitionTriggerBase(EntityTriggerBase):
|
||||
)
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected states."""
|
||||
return self._get_tracked_value(state) in self._to_states
|
||||
|
||||
@@ -777,11 +747,7 @@ class EntityOriginStateTriggerBase(EntityTriggerBase):
|
||||
)
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the new state is different from the origin state."""
|
||||
return bool(self._get_tracked_value(state) != self._from_state)
|
||||
|
||||
@@ -837,11 +803,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
|
||||
return True
|
||||
return unit == self._valid_unit
|
||||
|
||||
def _get_threshold_value(
|
||||
self,
|
||||
threshold: ThresholdConfig | None,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> float | None:
|
||||
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None:
|
||||
"""Get threshold value from float or entity state."""
|
||||
if threshold is None:
|
||||
return None
|
||||
@@ -850,29 +812,14 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
|
||||
|
||||
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
|
||||
# Entity not found
|
||||
report_not_triggered(
|
||||
"threshold_entity_not_found",
|
||||
entity_id=threshold.entity,
|
||||
)
|
||||
return None
|
||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
if not self._is_valid_unit(unit):
|
||||
if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
|
||||
# Entity unit does not match the expected unit
|
||||
report_not_triggered(
|
||||
"threshold_unit_not_supported",
|
||||
entity_id=threshold.entity,
|
||||
unit=unit,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return float(state.state)
|
||||
except TypeError, ValueError:
|
||||
# Entity state is not a valid number
|
||||
report_not_triggered(
|
||||
"threshold_value_not_numeric",
|
||||
entity_id=threshold.entity,
|
||||
value=state.state,
|
||||
)
|
||||
return None
|
||||
|
||||
@override
|
||||
@@ -893,46 +840,11 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
|
||||
# Entity state is not a valid number
|
||||
return None
|
||||
|
||||
def _report_tracked_value_problem(
|
||||
self, state: State, report_not_triggered: NotTriggeredReasonReporter
|
||||
) -> None:
|
||||
"""Report why `_get_tracked_value` rejected this state.
|
||||
|
||||
Called only when the tracked value is invalid. It mirrors the failure
|
||||
modes of `_get_tracked_value` - which integrations override, so the
|
||||
reason is derived here rather than reported inline: a state-sourced
|
||||
value with an unsupported unit, otherwise a value that is not a number.
|
||||
"""
|
||||
domain_spec = self._domain_specs[state.domain]
|
||||
raw_value: Any
|
||||
if domain_spec.value_source is None:
|
||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
if not self._is_valid_unit(unit):
|
||||
report_not_triggered(
|
||||
"entity_unit_not_supported",
|
||||
entity_id=state.entity_id,
|
||||
unit=unit,
|
||||
)
|
||||
return
|
||||
raw_value = state.state
|
||||
else:
|
||||
raw_value = state.attributes.get(domain_spec.value_source)
|
||||
report_not_triggered(
|
||||
"entity_value_not_numeric",
|
||||
entity_id=state.entity_id,
|
||||
value=raw_value,
|
||||
)
|
||||
|
||||
@override
|
||||
def is_valid_state(
|
||||
self,
|
||||
state: State,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> bool:
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state or state attribute matches the expected one."""
|
||||
# Handle missing or None value case first to avoid expensive exceptions
|
||||
if (current_value := self._get_tracked_value(state)) is None:
|
||||
self._report_tracked_value_problem(state, report_not_triggered)
|
||||
return False
|
||||
|
||||
if self._threshold_type == NumericThresholdType.ANY:
|
||||
@@ -941,32 +853,20 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
|
||||
return True
|
||||
|
||||
if self._threshold_type == NumericThresholdType.ABOVE:
|
||||
if (
|
||||
limit := self._get_threshold_value(self.threshold, report_not_triggered)
|
||||
) is None:
|
||||
if (limit := self._get_threshold_value(self.threshold)) is None:
|
||||
# Entity not found or invalid number, don't trigger
|
||||
return False
|
||||
return current_value > limit
|
||||
if self._threshold_type == NumericThresholdType.BELOW:
|
||||
if (
|
||||
limit := self._get_threshold_value(self.threshold, report_not_triggered)
|
||||
) is None:
|
||||
if (limit := self._get_threshold_value(self.threshold)) is None:
|
||||
# Entity not found or invalid number, don't trigger
|
||||
return False
|
||||
return current_value < limit
|
||||
|
||||
# Mode is BETWEEN or OUTSIDE. Evaluate the lower limit first so at most
|
||||
# one not-triggered reason is reported per change.
|
||||
lower_limit = self._get_threshold_value(
|
||||
self.lower_threshold, report_not_triggered
|
||||
)
|
||||
if lower_limit is None:
|
||||
# Entity not found or invalid number, don't trigger
|
||||
return False
|
||||
upper_limit = self._get_threshold_value(
|
||||
self.upper_threshold, report_not_triggered
|
||||
)
|
||||
if upper_limit is None:
|
||||
# Mode is BETWEEN or OUTSIDE
|
||||
lower_limit = self._get_threshold_value(self.lower_threshold)
|
||||
upper_limit = self._get_threshold_value(self.upper_threshold)
|
||||
if lower_limit is None or upper_limit is None:
|
||||
# Entity not found or invalid number, don't trigger
|
||||
return False
|
||||
between = lower_limit <= current_value <= upper_limit
|
||||
@@ -986,41 +886,7 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
|
||||
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
@override
|
||||
def _report_tracked_value_problem(
|
||||
self, state: State, report_not_triggered: NotTriggeredReasonReporter
|
||||
) -> None:
|
||||
"""Report why `_get_tracked_value` rejected this state.
|
||||
|
||||
Mirrors the with-unit failure modes: a value that is not a number,
|
||||
otherwise a unit that cannot be converted to the base unit.
|
||||
"""
|
||||
domain_spec = self._domain_specs[state.domain]
|
||||
raw_value: Any
|
||||
if domain_spec.value_source is None:
|
||||
raw_value = state.state
|
||||
else:
|
||||
raw_value = state.attributes.get(domain_spec.value_source)
|
||||
try:
|
||||
float(raw_value)
|
||||
except TypeError, ValueError:
|
||||
report_not_triggered(
|
||||
"entity_value_not_numeric",
|
||||
entity_id=state.entity_id,
|
||||
value=raw_value,
|
||||
)
|
||||
return
|
||||
report_not_triggered(
|
||||
"entity_unit_not_supported",
|
||||
entity_id=state.entity_id,
|
||||
unit=self._get_entity_unit(state),
|
||||
)
|
||||
|
||||
@override
|
||||
def _get_threshold_value(
|
||||
self,
|
||||
threshold: ThresholdConfig | None,
|
||||
report_not_triggered: NotTriggeredReasonReporter,
|
||||
) -> float | None:
|
||||
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None:
|
||||
"""Get threshold value from float or entity state."""
|
||||
if threshold is None:
|
||||
return None
|
||||
@@ -1033,32 +899,19 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
|
||||
|
||||
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
|
||||
# Entity not found
|
||||
report_not_triggered(
|
||||
"threshold_entity_not_found",
|
||||
entity_id=threshold.entity,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
value = float(state.state)
|
||||
except TypeError, ValueError:
|
||||
# Entity state is not a valid number
|
||||
report_not_triggered(
|
||||
"threshold_value_not_numeric",
|
||||
entity_id=threshold.entity,
|
||||
value=state.state,
|
||||
)
|
||||
return None
|
||||
|
||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
try:
|
||||
return self._unit_converter.convert(value, unit, self._base_unit)
|
||||
return self._unit_converter.convert(
|
||||
value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit
|
||||
)
|
||||
except HomeAssistantError:
|
||||
# Unit conversion failed (i.e. incompatible units), treat as invalid number
|
||||
report_not_triggered(
|
||||
"threshold_unit_not_supported",
|
||||
entity_id=threshold.entity,
|
||||
unit=unit,
|
||||
)
|
||||
return None
|
||||
|
||||
@override
|
||||
@@ -1155,7 +1008,7 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge
|
||||
@override
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check that the tracked value crossed into the threshold range."""
|
||||
return not self.is_valid_state(from_state, _report_not_triggered_noop)
|
||||
return not self.is_valid_state(from_state)
|
||||
|
||||
|
||||
def _make_numerical_state_crossed_threshold_with_unit_schema(
|
||||
@@ -1435,13 +1288,6 @@ class TriggerNotTriggeredReporter(Protocol):
|
||||
"""Report that the trigger did not fire."""
|
||||
|
||||
|
||||
class NotTriggeredReasonReporter(Protocol):
|
||||
"""Reports why an evaluated change did not fire an entity trigger."""
|
||||
|
||||
def __call__(self, reason: str, /, **data: Any) -> None:
|
||||
"""Report, with diagnostic data, why the change did not fire."""
|
||||
|
||||
|
||||
class TriggerNotTriggeredAction(Protocol):
|
||||
"""Protocol type for the did_not_trigger consumer callback.
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ orjson==3.11.9
|
||||
packaging>=23.1
|
||||
paho-mqtt==2.1.0
|
||||
Pillow==12.2.0
|
||||
probatio==0.5.4
|
||||
propcache==0.5.2
|
||||
psutil-home-assistant==0.0.1
|
||||
PyJWT==2.12.1
|
||||
@@ -71,9 +72,6 @@ typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==2.2.9
|
||||
urllib3>=2.0
|
||||
uv==0.11.21
|
||||
voluptuous-openapi==0.4.1
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
webrtc-models==0.3.0
|
||||
yarl==1.24.2
|
||||
zeroconf==0.150.0
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[mypy]
|
||||
python_version = 3.14
|
||||
platform = linux
|
||||
mypy_path = stubs
|
||||
plugins = pydantic.mypy
|
||||
show_error_codes = true
|
||||
follow_imports = normal
|
||||
|
||||
+2
-5
@@ -75,9 +75,7 @@ dependencies = [
|
||||
"ulid-transform==2.2.9",
|
||||
"urllib3>=2.0",
|
||||
"uv==0.11.21",
|
||||
"voluptuous==0.15.2",
|
||||
"voluptuous-serialize==2.7.0",
|
||||
"voluptuous-openapi==0.4.1",
|
||||
"probatio==0.5.4",
|
||||
"yarl==1.24.2",
|
||||
"webrtc-models==0.3.0",
|
||||
"zeroconf==0.150.0",
|
||||
@@ -648,7 +646,7 @@ exclude_lines = [
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
required-version = ">=0.15.17"
|
||||
required-version = ">=0.15.18"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
@@ -785,7 +783,6 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
|
||||
voluptuous = "vol"
|
||||
"homeassistant.components.air_quality.PLATFORM_SCHEMA" = "AIR_QUALITY_PLATFORM_SCHEMA"
|
||||
"homeassistant.components.alarm_control_panel.PLATFORM_SCHEMA" = "ALARM_CONTROL_PANEL_PLATFORM_SCHEMA"
|
||||
"homeassistant.components.binary_sensor.PLATFORM_SCHEMA" = "BINARY_SENSOR_PLATFORM_SCHEMA"
|
||||
|
||||
Generated
+1
-3
@@ -37,6 +37,7 @@ mutagen==1.47.0
|
||||
orjson==3.11.9
|
||||
packaging>=23.1
|
||||
Pillow==12.2.0
|
||||
probatio==0.5.4
|
||||
propcache==0.5.2
|
||||
psutil-home-assistant==0.0.1
|
||||
PyJWT==2.12.1
|
||||
@@ -56,9 +57,6 @@ typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==2.2.9
|
||||
urllib3>=2.0
|
||||
uv==0.11.21
|
||||
voluptuous-openapi==0.4.1
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
webrtc-models==0.3.0
|
||||
yarl==1.24.2
|
||||
zeroconf==0.150.0
|
||||
|
||||
Generated
+5
-2
@@ -653,6 +653,9 @@ bleak-esphome==3.9.4
|
||||
# homeassistant.components.bluetooth
|
||||
bleak-retry-connector==4.6.1
|
||||
|
||||
# homeassistant.components.smlight
|
||||
bleak-smlight==1.1.0
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak==3.0.2
|
||||
|
||||
@@ -1314,7 +1317,7 @@ ibeacon-ble==1.2.0
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==13.2.5
|
||||
ical==13.3.0
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.3.1
|
||||
@@ -3313,7 +3316,7 @@ volkszaehler==0.4.0
|
||||
volvocarsapi==0.4.3
|
||||
|
||||
# homeassistant.components.verisure
|
||||
vsure==2.7.1
|
||||
vsure==2.8.0
|
||||
|
||||
# homeassistant.components.vasttrafik
|
||||
vtjp==0.2.1
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
|
||||
|
||||
codespell==2.4.2
|
||||
ruff==0.15.17
|
||||
ruff==0.15.18
|
||||
yamllint==1.38.0
|
||||
zizmor==1.24.1
|
||||
|
||||
@@ -1 +1,13 @@
|
||||
"""Home Assistant scripts."""
|
||||
|
||||
import contextlib
|
||||
|
||||
# Scripts such as hassfest (and the libraries they import) use voluptuous. Alias it
|
||||
# to probatio before anything imports it, the same as homeassistant/__init__.py does
|
||||
# for the application itself. This must run before the first `import voluptuous`.
|
||||
# Some scripts (for example check_requirements) run before dependencies are
|
||||
# installed, so probatio may be absent; those scripts do not need the alias.
|
||||
with contextlib.suppress(ImportError):
|
||||
from probatio.compat import install_as_voluptuous
|
||||
|
||||
install_as_voluptuous()
|
||||
|
||||
@@ -31,6 +31,9 @@ HEADER: Final = """
|
||||
GENERAL_SETTINGS: Final[dict[str, str]] = {
|
||||
"python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]),
|
||||
"platform": "linux",
|
||||
# voluptuous is aliased to probatio at runtime; this stub path re-exports
|
||||
# probatio's types under the voluptuous name for not-yet-migrated integrations.
|
||||
"mypy_path": "stubs",
|
||||
"plugins": ", ".join( # noqa: FLY002
|
||||
[
|
||||
"pydantic.mypy",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from probatio import * # noqa: F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# See voluptuous/__init__.pyi. Re-exports probatio.error under the voluptuous name.
|
||||
from probatio.error import * # noqa: F403
|
||||
@@ -0,0 +1,5 @@
|
||||
# See voluptuous/__init__.pyi. Re-exports probatio.humanize under the voluptuous name.
|
||||
from probatio.humanize import (
|
||||
MAX_VALIDATION_ERROR_ITEM_LENGTH as MAX_VALIDATION_ERROR_ITEM_LENGTH,
|
||||
humanize_error as humanize_error,
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import voluptuous_serialize
|
||||
from probatio import serialize
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.auth import auth_manager_from_config, models as auth_models
|
||||
@@ -251,7 +251,7 @@ async def test_setup_user_notify_service(hass: HomeAssistant) -> None:
|
||||
schema = step["data_schema"]
|
||||
schema({"notify_service": "test2"})
|
||||
# ensure the schema can be serialized
|
||||
assert voluptuous_serialize.convert(schema) == [
|
||||
assert serialize(schema) == [
|
||||
{
|
||||
"name": "notify_service",
|
||||
"options": [
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_generate_data_service_structure_fields(
|
||||
},
|
||||
},
|
||||
vol.Invalid,
|
||||
r"extra keys not allowed.*",
|
||||
r"not a valid option.*",
|
||||
),
|
||||
(
|
||||
{
|
||||
@@ -248,7 +248,7 @@ async def test_generate_data_service_structure_fields(
|
||||
},
|
||||
},
|
||||
vol.Invalid,
|
||||
r"extra keys not allowed .*",
|
||||
r"not a valid option .*",
|
||||
),
|
||||
(
|
||||
{
|
||||
|
||||
@@ -275,9 +275,9 @@ async def test_subentry_options_thinking_budget_more_than_max(
|
||||
},
|
||||
)
|
||||
assert options["type"] is FlowResultType.FORM
|
||||
assert options["step_id"] == "advanced"
|
||||
assert options["step_id"] == "additional"
|
||||
|
||||
# Configure advanced step
|
||||
# Configure additional step
|
||||
options = await hass.config_entries.subentries.async_configure(
|
||||
options["flow_id"],
|
||||
{"chat_model": "claude-sonnet-4-5"},
|
||||
@@ -330,9 +330,9 @@ async def test_subentry_web_search_user_location(
|
||||
},
|
||||
)
|
||||
assert options["type"] is FlowResultType.FORM
|
||||
assert options["step_id"] == "advanced"
|
||||
assert options["step_id"] == "additional"
|
||||
|
||||
# Configure advanced step
|
||||
# Configure additional step
|
||||
options = await hass.config_entries.subentries.async_configure(
|
||||
options["flow_id"],
|
||||
{
|
||||
@@ -424,7 +424,7 @@ async def test_model_list(
|
||||
},
|
||||
)
|
||||
assert options["type"] is FlowResultType.FORM
|
||||
assert options["step_id"] == "advanced"
|
||||
assert options["step_id"] == "additional"
|
||||
assert options["data_schema"].schema["chat_model"].config["options"] == snapshot
|
||||
|
||||
|
||||
@@ -447,9 +447,9 @@ async def test_invalid_model(
|
||||
},
|
||||
)
|
||||
assert options["type"] is FlowResultType.FORM
|
||||
assert options["step_id"] == "advanced"
|
||||
assert options["step_id"] == "additional"
|
||||
|
||||
# Configure advanced step but with api error
|
||||
# Configure additional step but with api error
|
||||
with patch(
|
||||
"homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.retrieve",
|
||||
new_callable=AsyncMock,
|
||||
@@ -877,12 +877,12 @@ async def test_ai_task_subentry_not_loaded(
|
||||
assert result.get("reason") == "entry_not_loaded"
|
||||
|
||||
|
||||
async def test_creating_ai_task_subentry_advanced(
|
||||
async def test_creating_ai_task_subentry_additional(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry with advanced settings."""
|
||||
"""Test creating an AI task subentry with additional settings."""
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "ai_task_data"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
@@ -891,7 +891,7 @@ async def test_creating_ai_task_subentry_advanced(
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "init"
|
||||
|
||||
# Go to advanced settings
|
||||
# Go to additional settings
|
||||
result2 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
@@ -901,9 +901,9 @@ async def test_creating_ai_task_subentry_advanced(
|
||||
)
|
||||
|
||||
assert result2.get("type") is FlowResultType.FORM
|
||||
assert result2.get("step_id") == "advanced"
|
||||
assert result2.get("step_id") == "additional"
|
||||
|
||||
# Configure advanced settings
|
||||
# Configure additional settings
|
||||
result3 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from homeassistant.components.autoskope.const import (
|
||||
DEFAULT_HOST,
|
||||
DOMAIN,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
@@ -20,7 +20,7 @@ from tests.common import MockConfigEntry
|
||||
USER_INPUT = {
|
||||
CONF_USERNAME: "test_user",
|
||||
CONF_PASSWORD: "test_password",
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_HOST: DEFAULT_HOST,
|
||||
},
|
||||
}
|
||||
@@ -102,7 +102,7 @@ async def test_flow_invalid_url(
|
||||
{
|
||||
CONF_USERNAME: "test_user",
|
||||
CONF_PASSWORD: "test_password",
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_HOST: "not-a-valid-url",
|
||||
},
|
||||
},
|
||||
@@ -151,7 +151,7 @@ async def test_custom_host(
|
||||
{
|
||||
CONF_USERNAME: "test_user",
|
||||
CONF_PASSWORD: "test_password",
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_HOST: "https://custom.autoskope.server",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""The tests for Climate device actions."""
|
||||
|
||||
from probatio import serialize
|
||||
import pytest
|
||||
from pytest_unordered import unordered
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.climate import DOMAIN, HVACMode, const, device_action
|
||||
@@ -398,9 +398,7 @@ async def test_capabilities(
|
||||
assert capabilities and "extra_fields" in capabilities
|
||||
|
||||
assert (
|
||||
voluptuous_serialize.convert(
|
||||
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
|
||||
)
|
||||
serialize(capabilities["extra_fields"], custom_serializer=cv.custom_serializer)
|
||||
== expected_capabilities
|
||||
)
|
||||
|
||||
@@ -516,9 +514,7 @@ async def test_capabilities_legacy(
|
||||
assert capabilities and "extra_fields" in capabilities
|
||||
|
||||
assert (
|
||||
voluptuous_serialize.convert(
|
||||
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
|
||||
)
|
||||
serialize(capabilities["extra_fields"], custom_serializer=cv.custom_serializer)
|
||||
== expected_capabilities
|
||||
)
|
||||
|
||||
@@ -556,8 +552,6 @@ async def test_capabilities_missing_entity(
|
||||
assert capabilities and "extra_fields" in capabilities
|
||||
|
||||
assert (
|
||||
voluptuous_serialize.convert(
|
||||
capabilities["extra_fields"], custom_serializer=cv.custom_serializer
|
||||
)
|
||||
serialize(capabilities["extra_fields"], custom_serializer=cv.custom_serializer)
|
||||
== expected_capabilities
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user