Compare commits

..

30 Commits

Author SHA1 Message Date
Franck Nijhof aaedff9502 Remove dead URL no-op in Pushbullet notify 2026-06-29 16:14:24 +00:00
Franck Nijhof cb1f8ce440 Guard the probatio shim import for pre-install scripts 2026-06-29 18:05:15 +02:00
Franck Nijhof e0f5c99fdf Activate the probatio shim for script entrypoints 2026-06-29 18:05:15 +02:00
Franck Nijhof 2952bf3e1a Use probatio as the data validation engine 2026-06-29 18:05:15 +02:00
Allen Porter 1186f153cb Add bump-dependency development skill and PyPI resolver (#172221)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-06-29 16:43:55 +01:00
Jan Rieger bc9c270117 Cleanup gpsd strings (#175125) 2026-06-29 16:12:51 +02:00
Manuel Stahl 8922131056 Remove unused translations from stiebel_eltron integration (#175112) 2026-06-29 17:00:01 +03:00
Franck Nijhof e0ee456bfa Fix invalid marker usage in LIFX service schemas (#175117) 2026-06-29 15:26:04 +02:00
Maciej Bieniek ebeb98dd83 Add missing translation key in the NextDNS integration (#175120) 2026-06-29 15:15:57 +02:00
Onero-testdev 49cff5f980 Add Candle Warmer Lamp support to switchbot (#173585)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:15:26 +02:00
TimL c24186e571 Add Bluetooth proxy support for SMLIGHT (#174710)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-06-29 15:12:49 +02:00
FuNK3Y c9ea8baf61 Bump ical to 13.3.0 (#175090)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:53:58 +02:00
Guillaume Winter f21426dfa6 Add fan speed sensor to Freebox integration (#175081) 2026-06-29 14:50:56 +02:00
Ariel Ebersberger a450999646 Rename "advanced_settings" section to "additional_settings" in Autoskope (#175106) 2026-06-29 14:50:18 +02:00
Ariel Ebersberger 5b8ff19d8d Rename "advanced_settings" section to "additional_settings" in Telegram bot (#175107) 2026-06-29 14:48:47 +02:00
Ronald van der Meer 25d505bcf3 Fix Duco ventilation sensors not being created for valve nodes (#174971) 2026-06-29 14:47:19 +02:00
Franck Nijhof 91cb829881 Fix invalid use of Exclusive marker in Kira name schema (#175115) 2026-06-29 14:44:02 +02:00
renovate[bot] 4fdc4e6219 Update ruff to v0.15.18 (#175087) 2026-06-29 14:41:47 +02:00
Ariel Ebersberger ca7ae00c7e Rename "advanced_settings" section to "additional_settings" in History Stats (#175109) 2026-06-29 14:32:57 +02:00
Ariel Ebersberger ff460901b7 Rename "advanced_options" section to "additional_options" in DNS IP (#175105) 2026-06-29 13:14:58 +02:00
Ariel Ebersberger 30512f08a8 Rename "advanced" step to "additional" in OpenAI Conversation (#175104) 2026-06-29 13:14:19 +02:00
Ariel Ebersberger 9dd1a59d50 Rename "advanced" step to "additional" in Anthropic (#175101) 2026-06-29 13:14:01 +02:00
TrojanHorsePower 91aded4474 Update vsure to 2.8.0 (#175060) 2026-06-29 11:46:21 +02:00
Erwin Douna 543eab3354 Portainer add utility.py for duplicate code (#175082) 2026-06-29 11:04:02 +02:00
Ariel Ebersberger 47b331a869 Rename "Advanced settings" title in OpenAI Conversation (#175096) 2026-06-29 11:00:21 +02:00
Ariel Ebersberger 696dd45803 Rename "Advanced settings" title in Anthropic (#175095) 2026-06-29 10:59:53 +02:00
Ariel Ebersberger f92239877f Rename "Advanced migration/setup" options in ZHA (#175094) 2026-06-29 10:59:35 +02:00
Ariel Ebersberger 45ceb13937 Rename "Advanced settings" sections in Scrape (#175098) 2026-06-29 10:58:26 +02:00
Ariel Ebersberger c5aeee8097 Rename "(advanced)" service name in Music Assistant (#175097) 2026-06-29 10:58:04 +02:00
Erik Montnemery bfc750b608 Tell bots that we like small try-clauses (#174654) 2026-06-29 11:16:46 +03:00
150 changed files with 1912 additions and 1192 deletions
+98
View File
@@ -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()
+1
View File
@@ -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 -1
View File
@@ -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:
+1
View File
@@ -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.
+8
View File
@@ -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
),
+1 -1
View File
@@ -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": {
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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
+4 -21
View File
@@ -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
+2 -10
View File
@@ -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
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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."
}
+2 -10
View File
@@ -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
+14
View File
@@ -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,
)
+2 -16
View File
@@ -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)
+4 -4
View File
@@ -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",
+1 -6
View File
@@ -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
+13 -1
View File
@@ -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):
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)),
}
+3 -7
View File
@@ -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"]
}
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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]]
+2 -1
View File
@@ -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"]
}
+4 -4
View File
@@ -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"
}
}
}
+19 -6
View File
@@ -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,
+3 -17
View File
@@ -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"
+2 -2
View File
@@ -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(
+4 -21
View File
@@ -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
+1 -1
View File
@@ -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}"
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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,
+25 -179
View File
@@ -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.
+1 -3
View File
@@ -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
Generated
+1
View File
@@ -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
View File
@@ -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"
+1 -3
View File
@@ -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
+5 -2
View File
@@ -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
+1 -1
View File
@@ -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
+12
View File
@@ -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()
+3
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
from probatio import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
# See voluptuous/__init__.pyi. Re-exports probatio.error under the voluptuous name.
from probatio.error import * # noqa: F403
+5
View File
@@ -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,
)
+2 -2
View File
@@ -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": [
+2 -2
View File
@@ -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 .*",
),
(
{
+12 -12
View File
@@ -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",
},
},
+4 -10
View File
@@ -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